Skip to content

Commit

Permalink
Token Authentication (#101)
Browse files Browse the repository at this point in the history
* Added client_secrets.json to ignore files

* Added Google OAuth to dependencies

* Created Initial Structure of Backend Authorization

* Removed unnecessary print statements

* Moved OAuth business logic to service

* Endpoint now exists to log a user out

* Removed accidental duplicate claim

* OAuth controller now redirects to frontend

* Added whoami endpoint for frontend

* Added credentials to cors policy

* Simplified Claims Schema

* GetUserClaims does not accept null AuthenticateResult now

* Added whoami auth endpoint

* Side-Nav logs out from backend

* Login flow now redirects to backend

* Removed local GoogleOAuthProvider

* Added auth url backend to frontend env

* Logout endpoint now works even when a user is not logged in

* Added logout redirection with path host validation

* Continue as Guest now logs out and redirects to loggedIn

* Documentation on whoami route

* Added dotenv support

* Added app/ directory to gitignore

* Added documentation about google cloud project

* Docker now supports google environment variables and added documentation
  • Loading branch information
LucientZ authored Feb 17, 2025
1 parent 02d07e9 commit 597b221
Show file tree
Hide file tree
Showing 22 changed files with 331 additions and 120 deletions.
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ ChemistryCafe is a web application built with React, Vite, and TypeScript. The a

## Getting Started

### Backend Environment Variables

The backend of Chemistry Cafe requires certain secrets that cannot be stored in version control. These secrets are stored in environment variables that are either on the machine or loaded on runtime. Before running the application, make sure to define these variables ahead of time.

To define required environment variables, a `.env` file should be created with the following schema:

```py
# Required
GOOGLE_CLIENT_ID=<client_id>
GOOGLE_CLIENT_SECRET=<client_secret>
MYSQL_USER=chemistrycafedev
MYSQL_PASSWORD=chemistrycafe
MYSQL_DATABASE=chemistry_db

# Optional with defaults
MYSQL_SERVER=localhost
MYSQL_PORT=3306
```

In order to use Google Authentication, a Google Cloud OAuth 2.0 project must be used with a `client id` and `client secret`. When creating the project, `http://localhost:8080/signin-google` should be added to the list of "Authorized redirect URIs" for testing.

**Note:**

- When running locally, the `.env` file must be in the `/backend` directory.
- When running with docker, the `.env` file can either be in the root directory *or* `/backend`. If it is in another directory, simply use `docker compose --env-file <path/to/.env> up` instead of the default.

### Running Chemistry Cafe with Docker Compose

You must have [Docker Desktop](https://www.docker.com/get-started) installed and running.
Expand Down Expand Up @@ -48,7 +74,6 @@ To view logs for all services:
docker compose logs -f
```


**Note:** To view changes, you must run the docker compose down and then run the project again.

### Local Development (without Docker)
Expand Down
4 changes: 3 additions & 1 deletion backend/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
!ChemistryCafeAPI.csproj
!ChemistryCafeAPI.http
!Citation.cff
!client_secrets.json
!Controllers
!LICENSE
!Models
Expand All @@ -16,4 +17,5 @@
!Startup.cs
!appsettings.Development.json
!appsettings.Production.json
!appsettings.json
!appsettings.json
!.env
5 changes: 5 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
# dotenv files
.env

# OAuth Credentials
client_secrets.json


# User-specific files
*.rsuser
*.suo
Expand Down Expand Up @@ -34,6 +38,7 @@ bld/
[Oo]bj/
[Ll]og/
[Ll]ogs/
app/

# Visual Studio 2015/2017 cache/options directory
.vs/
Expand Down
5 changes: 3 additions & 2 deletions backend/ChemistryCafeAPI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="dotenv.net" Version="3.2.1" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageReference Include="Microsoft.CodeCoverage" Version="17.11.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
Expand All @@ -48,8 +50,7 @@
<PackageReference Include="MySqlConnector.DependencyInjection" Version="2.3.5" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage"
Version="17.10.1" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.10.1" />
</ItemGroup>

</Project>
117 changes: 117 additions & 0 deletions backend/Controllers/GoogleOAuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Security.Claims;
using Chemistry_Cafe_API.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Protocols.Configuration;

namespace Chemistry_Cafe_API.Controllers
{
/// <summary>
/// Controls routes related to Google OAuth 2.0 authentication
/// </summary>
[Route("/auth/google")]
public class GoogleOAuthController : Controller
{
private readonly GoogleOAuthService _googleOAuthService;
private readonly IConfiguration _configuration;
public GoogleOAuthController(IConfiguration configuration, GoogleOAuthService googleOAuthService)
{
_googleOAuthService = googleOAuthService;
_configuration = configuration;
}

/// <summary>
/// Route which the user redirects to a google authentication page
/// </summary>
[HttpGet("login")]
public IActionResult LoginRedirect()
{
AuthenticationProperties authProperties = new AuthenticationProperties { RedirectUri = "/auth/google/authenticate" };
return new ChallengeResult(GoogleDefaults.AuthenticationScheme, authProperties);
}

/// <summary>
/// Route that user will be redirected to after signing in with Google OAuth.
/// This route essentially sets a user's information in a cookie.
/// </summary>
[HttpGet("authenticate")]
public async Task<IActionResult> GoogleResponse()
{
AuthenticateResult result = await HttpContext.AuthenticateAsync("External");
if (!result.Succeeded)
{
return BadRequest("Google OAuth Http Response did not succeed");
}

ClaimsPrincipal? claimsIdentity = _googleOAuthService.GetUserClaims(result);
if (claimsIdentity == null)
{
return BadRequest("Invalid Credentials Passed");
}

await HttpContext.SignInAsync("Application", claimsIdentity);
string redirectUrl = (_configuration["FrontendHost"] ?? throw new InvalidConfigurationException("")) + "/LoggedIn";
return Redirect(redirectUrl);
}

/// <summary>
/// Removes all authentication cookies and signs a user out of the backend application
/// </summary>
[HttpGet("logout")]
public async Task<IActionResult> Logout(string? returnUrl)
{
string frontendHost = _configuration["FrontendHost"] ?? throw new InvalidConfigurationException("'FrontendHost' key not set in appsettings");
// Ensure the redirect url is
if (returnUrl == null || returnUrl.Equals(""))
{
returnUrl = frontendHost;
}
else if (!Url.IsLocalUrl(returnUrl) && !returnUrl.StartsWith(frontendHost))
{
return BadRequest("Invalid returnUrl argument. Must be within application scope.");
}

await HttpContext.SignOutAsync("Application");

var request = HttpContext.Request;
var cookies = request.Cookies;
if (cookies.Count > 0)
{
foreach (var cookie in cookies)
{
if (cookie.Key.Contains(".AspNetCore.") || cookie.Key.Contains("Microsoft.Authentication"))
{
Response.Cookies.Delete(cookie.Key);
}
}
}

return Redirect(returnUrl);
}

/// <summary>
/// Gives the user information on themselves
/// </summary>
[HttpGet("whoami")]
public UserClaims GetUserClaims()
{
ClaimsIdentity? claimsIdentity = HttpContext.User.Identity as ClaimsIdentity;
if (claimsIdentity == null)
{
return new UserClaims
{
NameIdentifier = null,
EmailClaim = null,
};
}

return new UserClaims
{
NameIdentifier = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier)?.Value,
EmailClaim = claimsIdentity.FindFirst(ClaimTypes.Email)?.Value
};
}
}
}
17 changes: 17 additions & 0 deletions backend/Models/UserClaims.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;

namespace Chemistry_Cafe_API.Models
{
/// <summary>
/// Represents a user's authentication claims.
/// These are set in the user's cookies when the user logs in.
/// </summary>
public partial class UserClaims
{
[JsonPropertyName("nameId")]
public string? NameIdentifier { get; set; }

[JsonPropertyName("email")]
public string? EmailClaim { get; set; }
}
}
38 changes: 32 additions & 6 deletions backend/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Chemistry_Cafe_API.Services;
using MySqlConnector;
using Microsoft.AspNetCore.HttpOverrides;
using Chemistry_Cafe_API.Controllers;
using dotenv.net;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -9,6 +11,9 @@
builder.WebHost.UseUrls("http://0.0.0.0:5000");
}

// Configure Environment
DotEnv.Load();

// Add services to the container.

builder.Services.AddControllers();
Expand All @@ -23,6 +28,24 @@
builder.Services.AddScoped<OpenAtmosService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<PropertyService>();
builder.Services.AddScoped<GoogleOAuthService>();

string googleClientId = Environment.GetEnvironmentVariable("GOOGLE_CLIENT_ID") ?? throw new InvalidOperationException("GOOGLE_CLIENT_ID environment variable is missing.");
string googleClientSecret = Environment.GetEnvironmentVariable("GOOGLE_CLIENT_SECRET") ?? throw new InvalidOperationException("GOOGLE_CLIENT_SECRET environment variable is missing.");

builder.Services.AddAuthentication((options) =>
{
options.DefaultScheme = "Application";
options.DefaultSignInScheme = "External";
})
.AddCookie("Application")
.AddCookie("External")
.AddGoogle((options) =>
{
options.ClientId = googleClientId;
options.ClientSecret = googleClientSecret;
});

//builder.Services.AddScoped<TimeService>();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
Expand All @@ -40,18 +63,20 @@

builder.Services.AddCors(options =>
{
options.AddPolicy("DevelopmentCorsPolicy", builder =>
options.AddPolicy("DevelopmentCorsPolicy", policy =>
{
builder.WithOrigins("http://localhost:5173")
policy.WithOrigins("http://localhost:5173")
.AllowAnyMethod()
.AllowAnyHeader();
.AllowAnyHeader()
.AllowCredentials();
});

options.AddPolicy("ProductionCorsPolicy", builder =>
options.AddPolicy("ProductionCorsPolicy", policy =>
{
builder.WithOrigins("https://cafe-deux-devel.acom.ucar.edu")
policy.WithOrigins("https://cafe-deux-devel.acom.ucar.edu")
.AllowAnyMethod()
.AllowAnyHeader();
.AllowAnyHeader()
.AllowCredentials();
});
});

Expand All @@ -72,6 +97,7 @@
});
}

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
13 changes: 11 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,28 @@ API for the Chemistry Cafe web application found here https://github.com/NCAR/ch

# Getting Started

## Google Cloud

In order to use Google Authentication, a Google Cloud OAuth 2.0 project must be used for testing. When creating the project, `http://localhost:8080/signin-google` should be added to the list of Authorized redirect URIs.

## Environment Variables

For the backend to connect to the MySQL server, certain environment variables must be specified. The default values can be seen in `docker-compose.yml`. These variables are the following:

```py
MYSQL_SERVER=localhost
# Required
GOOGLE_CLIENT_ID=<client_id>
GOOGLE_CLIENT_SECRET=<client_secret>
MYSQL_USER=chemistrycafedev
MYSQL_PASSWORD=chemistrycafe
MYSQL_DATABASE=chemistry_db

# Optional with defaults
MYSQL_SERVER=localhost
MYSQL_PORT=3306
```

The only ones that are required are `MYSQL_USER`, `MYSQL_PASSWORD`, and `MYSQL_DATABASE`. `MYSQL_SERVER` defaults to "localhost" and `MYSQL_PORT` defaults to "3306".
`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` can be found in a google cloud project. These variables may be set in a `.env` file in the `/backend` directory or created on the machine itself.

## Command line

Expand Down
53 changes: 53 additions & 0 deletions backend/Services/GoogleOAuthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;

namespace Chemistry_Cafe_API.Controllers
{
/// <summary>
/// Adapted from: https://blog.rashik.com.np/adding-google-authentication-in-net-core-application-without-identity/
/// </summary>
public class GoogleOAuthService
{
/// <summary>
/// Parses an OAuth challenge result and turns them into a user's claims
/// </summary>
/// <param name="authenticateResult">Result of Google OAuth Challenge</param>
/// <returns>ClaimsPrincipal object which holds the user's auth informations</returns>
public ClaimsPrincipal? GetUserClaims(AuthenticateResult authenticateResult)
{
if (authenticateResult.Principal == null)
{
return null;
}

var identityList = authenticateResult.Principal.Identities.ToList();
if (authenticateResult.Principal != null && identityList.Count > 0)
{
var identity = identityList[0];
if (identity.AuthenticationType != null && identity.AuthenticationType.ToLower().Equals("google"))
{
var claimsIdentity = new ClaimsIdentity("Application");
var nameIdClaim = authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier); // GUID specified by Google
var emailClaim = authenticateResult.Principal.FindFirst(ClaimTypes.Email);

if (nameIdClaim != null && emailClaim != null)
{
claimsIdentity.AddClaim(nameIdClaim);
claimsIdentity.AddClaim(emailClaim);
}
else
{
return null;
}
return new ClaimsPrincipal(claimsIdentity);
}
else
{
return null;
}
}
return null;
}
}
}
Loading

0 comments on commit 597b221

Please sign in to comment.