Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgrade packages to latest compatible versions in backend and frontend [#29](https://github.com/building-envelope-data/database/pull/29)
- Persist data protection keys in database [bbac9c7e84a21dcc67bc5a9446032b862af51b5b](https://github.com/building-envelope-data/database/commit/bbac9c7e84a21dcc67bc5a9446032b862af51b5b)
- Build Docker images with BuildKit [4ce5fa2c4dbe67de14f5e1c9be22896921349b50](https://github.com/building-envelope-data/database/commit/4ce5fa2c4dbe67de14f5e1c9be22896921349b50)
-
- Improve OpenId Connect usage and security [#46](https://github.com/building-envelope-data/database/pull/46)
-
-
-
Expand Down
42 changes: 37 additions & 5 deletions backend/src/Configuration/AuthConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting;
using OpenIddict.Client;
using Microsoft.Extensions.Hosting;
Expand All @@ -17,6 +16,7 @@ namespace Database.Configuration
// https://github.com/openiddict/openiddict-samples/blob/855c31f91d6bf5cde735ef3f96fcc3c015b51d79/samples/Velusia/Velusia.Client/Startup.cs
public abstract class AuthConfiguration
{
public const string ClientId = "testlab-solar-facades";
public static string ReadApiScope { get; } = "api:read";
public static string WriteApiScope { get; } = "api:write";

Expand All @@ -28,7 +28,7 @@ AppSettings appSettings
{
var encryptionCertificate = LoadCertificate("jwt-encryption-certificate.pfx", appSettings.JsonWebToken.EncryptionCertificatePassword);
var signingCertificate = LoadCertificate("jwt-signing-certificate.pfx", appSettings.JsonWebToken.SigningCertificatePassword);
ConfigureAuthenticiationAndAuthorizationServices(services);
ConfigureAuthenticationAndAuthorizationServices(services);
ConfigureTaskScheduling(services, environment);
ConfigureOpenIddictServices(services, appSettings, encryptionCertificate, signingCertificate);
}
Expand All @@ -54,15 +54,26 @@ string password
);
}

private static void ConfigureAuthenticiationAndAuthorizationServices(
private static void ConfigureAuthenticationAndAuthorizationServices(
IServiceCollection services
)
{
// Dot not use the single authentication scheme as the default scheme
// https://learn.microsoft.com/en-us/aspnet/core/security/authentication/?view=aspnetcore-7.0#defaultscheme
AppContext.SetSwitch("Microsoft.AspNetCore.Authentication.SuppressAutoDefaultScheme", isEnabled: true);
// Inspired by https://github.com/openiddict/openiddict-samples/blob/01cb2ce4600cab15867e34826b0287622e6dd71b/samples/Velusia/Velusia.Client/Startup.cs
services
.AddAuthentication(_ =>
{
_.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// To make the various authentication control flows obvious, do
// not use default schemes for anything and always be explicit
// instead.
_.DefaultAuthenticateScheme = null;
_.DefaultChallengeScheme = null;
_.DefaultForbidScheme = null;
_.DefaultScheme = null;
_.DefaultSignInScheme = null;
_.DefaultSignOutScheme = null;
})
.AddCookie(_ =>
{
Expand Down Expand Up @@ -124,6 +135,25 @@ X509Certificate2 signingCertificate
// Enable Quartz.NET integration.
_.UseQuartz();
})
.AddValidation(_ =>
{
// The validation handler uses OpenID Connect discovery to
// retrieve the issuer signing keys used to validate tokens.
_.SetIssuer(new Uri(appSettings.MetabaseHost, UriKind.Absolute));
// Configure the audience accepted by this resource server.
_.AddAudiences(ClientId);
// Configure the validation handler to use introspection and
// register the client credentials used when communicating with
// the remote introspection endpoint.
// https://www.oauth.com/oauth2-servers/token-introspection-endpoint/
_.UseIntrospection()
.SetClientId(ClientId)
.SetClientSecret(appSettings.OpenIdConnectClientSecret);
// Register the ASP.NET Core host.
_.UseAspNetCore();
// Register the System.Net.Http integration.
_.UseSystemNetHttp();
})
.AddClient(_ =>
{
_.AllowAuthorizationCodeFlow();
Expand Down Expand Up @@ -154,12 +184,14 @@ X509Certificate2 signingCertificate

// Note: these settings must match the application details
// inserted in the database at the server level.
ClientId = "testlab-solar-facades",
ClientId = ClientId,
ClientSecret = appSettings.OpenIdConnectClientSecret,

// https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes#standard-claims
Scopes = {
OpenIddictConstants.Scopes.Address,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Phone,
OpenIddictConstants.Scopes.Profile,
OpenIddictConstants.Scopes.Roles,
ReadApiScope,
Expand Down
18 changes: 12 additions & 6 deletions backend/src/Configuration/GraphQlConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Database.GraphQl.Filters;
using System.Net.Http;
using OpenIddict.Validation.AspNetCore;

namespace Database.Configuration
{
Expand Down Expand Up @@ -93,13 +94,18 @@ AppSettings appSettings
/* .UsePersistedQueryPipeline(); */
.AddHttpRequestInterceptor(async (httpContext, requestExecutor, requestBuilder, cancellationToken) =>
{
// HotChocolate uses the default cookie authentication
// scheme `IdentityConstants.ApplicationScheme` by
// default.
var authenticateResult = await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
if (authenticateResult.Succeeded && authenticateResult.Principal is not null)
// HotChocolate uses the default authentication scheme, which we
// set to `null` in `AuthConfiguration` to force users to be
// explicit about what scheme to use when making it easier to
// grasp the various authentication flows.
try
{
httpContext.User = authenticateResult.Principal;
await HttpContextAuthentication.Authenticate(httpContext);
}
catch (Exception e)
{
// TODO Log to a `ILogger<GraphQlConfiguration>` instead.
Console.WriteLine(e);
}
})
.AddDiagnosticEventListener(_ =>
Expand Down
45 changes: 45 additions & 0 deletions backend/src/Configuration/HttpContextAuthentication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using OpenIddict.Validation.AspNetCore;

namespace Database.Configuration
{
public static class HttpContextAuthentication
{
public static async Task<AuthenticateResult> Authenticate(
HttpContext httpContext
)
{
// For the Next.js Web frontend, the database acts as OpenId Connect
// Client and uses the cookie scheme for authentication. See
// `AuthConfiguration#ConfigureAuthenticationAndAuthorizationServices`
// for the configuration of the "cookie scheme" cookie. This cookie
// is set by methods in `AuthenticationController` and is related to
// `OpenIddictBuilder#AddClient` in
// `AuthConfiguration#ConfigureOpenIddictServices`.
var cookieAuthenticateResult = await httpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
if (cookieAuthenticateResult.Succeeded && cookieAuthenticateResult.Principal is not null)
{
httpContext.User = cookieAuthenticateResult.Principal;
return cookieAuthenticateResult;
}
// For third-party frontends, the database acts as resource server
// and uses authorization-header bearer tokens for authentication,
// that is JavaScript Web Tokens (JWT), aka, Access Tokens, provided
// as `Authorization` HTTP header with the prefix `Bearer` as issued
// by OpenIddict. This Access Token includes Scopes and Claims. The
// scheme is configured in
// `AuthConfiguration#ConfigureOpenIddictServices` by
// `OpenIddictBuilder#AddValidation`.
var jwtAuthenticateResult = await httpContext.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme).ConfigureAwait(false);
if (jwtAuthenticateResult.Succeeded && jwtAuthenticateResult.Principal is not null)
{
httpContext.User = jwtAuthenticateResult.Principal;
return jwtAuthenticateResult;
}
return AuthenticateResult.Fail("All available authentication schemes failed or yielded no claims principal.");
}
}
}
41 changes: 41 additions & 0 deletions backend/src/Controllers/AntiforgeryController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Threading.Tasks;
using Database.Configuration;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace Database.Controllers
{
// For gotchas regarding antiforgery tokens read
// [Clarity around IAntiforgery and ValidateAntiForgeryToken](https://github.com/dotnet/aspnetcore/issues/2783)
public sealed class AntiforgeryController : Controller
{
private const string XsrfCookieKey = "XSRF-TOKEN";
private readonly CookieOptions XsrfCookieOptions =
new()
{
HttpOnly = false
};

private readonly IAntiforgery _antiforgeryService;

public AntiforgeryController(IAntiforgery antiforgeryService)
{
_antiforgeryService = antiforgeryService;
}

[HttpGet("~/antiforgery/token")]
public async Task<IActionResult> Token()
{
await HttpContextAuthentication.Authenticate(HttpContext).ConfigureAwait(false);
var tokens = _antiforgeryService.GetAndStoreTokens(HttpContext);
HttpContext.Response.Cookies.Append(
XsrfCookieKey,
tokens.RequestToken ?? throw new InvalidOperationException("The request token supposed to be generated by the antiforgery service is null."),
XsrfCookieOptions
);
return new StatusCodeResult(StatusCodes.Status200OK);
}
}
}
25 changes: 10 additions & 15 deletions backend/src/Controllers/AuthenticationControllers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Client.AspNetCore;
Expand Down Expand Up @@ -51,7 +52,7 @@ public ActionResult LogIn(string? returnUrl)
}

[HttpPost("~/connect/logout")]
// TODO Add `[ValidateAntiForgeryToken]`. For details see https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery?view=aspnetcore-7.0#javascript-ajax-and-spas
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme), ValidateAntiForgeryToken]
public async Task<ActionResult> LogOut(string? returnUrl)
{
// Retrieve the identity stored in the local authentication cookie. If it's not available,
Expand All @@ -60,7 +61,8 @@ public async Task<ActionResult> LogOut(string? returnUrl)
if (result is not { Succeeded: true })
{
// Only allow local return URLs to prevent open redirect attacks.
return Redirect(SanitizeReturnUrl(returnUrl));
// https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects
return LocalRedirect(SanitizeReturnUrl(returnUrl));
}
// Remove the local authentication cookie before triggering a redirection to the remote server.
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
Expand Down Expand Up @@ -141,9 +143,6 @@ public async Task<ActionResult> LogInCallback(CancellationToken cancellationToke
// the default claim type used by .NET and is required by the antiforgery components.
{ Type: Claims.Subject }
=> new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer),
// Map the standard "email" claim to ClaimTypes.Email.
{ Type: Claims.Email }
=> new Claim(ClaimTypes.Email, claim.Value, claim.ValueType, claim.Issuer),
// Map the standard "name" claim to ClaimTypes.Name.
{ Type: Claims.Name }
=> new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer),
Expand All @@ -152,7 +151,7 @@ public async Task<ActionResult> LogInCallback(CancellationToken cancellationToke
.Where(claim => claim switch
{
// Preserve the basic claims that are necessary for the application to work correctly.
{ Type: ClaimTypes.NameIdentifier or ClaimTypes.Email or ClaimTypes.Name } => true,
{ Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true,
// Don't preserve the other claims.
_ => false
}));
Expand Down Expand Up @@ -189,9 +188,6 @@ OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
var subject =
identity.FindFirst(c => c.Type == ClaimTypes.NameIdentifier)?.Value
?? throw new InvalidOperationException($"Impossible! The claim {ClaimTypes.NameIdentifier}, which is the subject of the token, is missing for the identity with name {identity.Name}.");
var email =
identity.FindFirst(c => c.Type == ClaimTypes.Email)?.Value
?? throw new InvalidOperationException($"Impossible! The claim {ClaimTypes.Email} is missing for the identity with subject {subject}.");
var name =
identity.FindFirst(c => c.Type == ClaimTypes.Name)?.Value
?? throw new InvalidOperationException($"Impossible! The claim {ClaimTypes.Name} is missing for the identity with subject {subject}.");
Expand All @@ -206,17 +202,13 @@ OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
_context.Users.Add(
new Data.User(
subject: subject,
email: email,
name: name
)
);
}
else
{
user.Update(
email: email,
name: name
);
user.Update(name: name);
}
await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
// Ask the cookie authentication handler to return a new cookie and redirect
Expand All @@ -239,7 +231,10 @@ public async Task<ActionResult> LogOutCallback()
// In this sample, the local authentication cookie is always removed before the user agent is redirected
// to the authorization server. Applications that prefer delaying the removal of the local cookie can
// remove the corresponding code from the logout action and remove the authentication cookie in this action.
return Redirect(
//
// Only allow local return URLs to prevent open redirect attacks.
// https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects
return LocalRedirect(
SanitizeReturnUrl(result.Properties?.RedirectUri)
);
}
Expand Down
7 changes: 1 addition & 6 deletions backend/src/Data/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,22 @@ namespace Database.Data
public sealed class User : Entity
{
public string Subject { get; private set; }
public string Email { get; private set; }
public string Name { get; private set; }

public User(
string subject,
string email,
string name
)
{
Subject = subject;
Email = email;
Name = name;
}

public void Update(
string email,
string name
)
{
Email = email;
Name = name;
}
}
}
}
1 change: 1 addition & 0 deletions backend/src/Database.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
<PackageReference Include="Yoh.Text.Json.NamingPolicies" Version="1.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion backend/src/GraphQl/AppliedMethodInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Database.GraphQl
{
public record AppliedMethodInput(
public sealed record AppliedMethodInput(
Guid MethodId,
IReadOnlyList<NamedMethodArgumentInput> Arguments,
IReadOnlyList<NamedMethodSourceInput> Sources
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace Database.GraphQl.CalorimetricDataX
{
public record CreateCalorimetricDataInput(
public sealed record CreateCalorimetricDataInput(
string AccessToken,
// TODO Why does specifying the type with an attribute not work here?
[GraphQLType<NonNullType<LocaleType>>] string Locale,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/GraphQl/CielabColorInput.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Database.GraphQl
{
public record CielabColorInput(
public sealed record CielabColorInput(
double LStar,
double AStar,
double BStar
Expand Down
2 changes: 1 addition & 1 deletion backend/src/GraphQl/Common/OpenEndedDateTimeRangeInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Database.GraphQl.Common
{
public record OpenEndedDateTimeRangeInput(
public sealed record OpenEndedDateTimeRangeInput(
DateTime? From,
DateTime? Until
);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/GraphQl/CrossDatabaseDataReferenceInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Database.GraphQl
{
public record CrossDatabaseDataReferenceInput(
public sealed record CrossDatabaseDataReferenceInput(
Guid DataId,
DateTime DataTimestamp,
DataKind DataKind,
Expand Down
Loading