diff --git a/CHANGELOG.md b/CHANGELOG.md index 089139b4..46d52d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - - - diff --git a/backend/src/Configuration/AuthConfiguration.cs b/backend/src/Configuration/AuthConfiguration.cs index 9b0ece37..4809e1b7 100644 --- a/backend/src/Configuration/AuthConfiguration.cs +++ b/backend/src/Configuration/AuthConfiguration.cs @@ -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; @@ -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"; @@ -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); } @@ -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(_ => { @@ -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(); @@ -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, diff --git a/backend/src/Configuration/GraphQlConfiguration.cs b/backend/src/Configuration/GraphQlConfiguration.cs index 8739e7f1..3b7042ab 100644 --- a/backend/src/Configuration/GraphQlConfiguration.cs +++ b/backend/src/Configuration/GraphQlConfiguration.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Database.GraphQl.Filters; using System.Net.Http; +using OpenIddict.Validation.AspNetCore; namespace Database.Configuration { @@ -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` instead. + Console.WriteLine(e); } }) .AddDiagnosticEventListener(_ => diff --git a/backend/src/Configuration/HttpContextAuthentication.cs b/backend/src/Configuration/HttpContextAuthentication.cs new file mode 100644 index 00000000..4a58adbf --- /dev/null +++ b/backend/src/Configuration/HttpContextAuthentication.cs @@ -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 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."); + } + } +} \ No newline at end of file diff --git a/backend/src/Controllers/AntiforgeryController.cs b/backend/src/Controllers/AntiforgeryController.cs new file mode 100644 index 00000000..27df8253 --- /dev/null +++ b/backend/src/Controllers/AntiforgeryController.cs @@ -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 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); + } + } +} \ No newline at end of file diff --git a/backend/src/Controllers/AuthenticationControllers.cs b/backend/src/Controllers/AuthenticationControllers.cs index ca2045d9..341e3c25 100644 --- a/backend/src/Controllers/AuthenticationControllers.cs +++ b/backend/src/Controllers/AuthenticationControllers.cs @@ -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; @@ -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 LogOut(string? returnUrl) { // Retrieve the identity stored in the local authentication cookie. If it's not available, @@ -60,7 +61,8 @@ public async Task 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); @@ -141,9 +143,6 @@ public async Task 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), @@ -152,7 +151,7 @@ public async Task 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 })); @@ -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}."); @@ -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 @@ -239,7 +231,10 @@ public async Task 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) ); } diff --git a/backend/src/Data/User.cs b/backend/src/Data/User.cs index 3f022754..5d043007 100644 --- a/backend/src/Data/User.cs +++ b/backend/src/Data/User.cs @@ -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; - } + } } } \ No newline at end of file diff --git a/backend/src/Database.csproj b/backend/src/Database.csproj index 2dd7453a..c7d6ad96 100644 --- a/backend/src/Database.csproj +++ b/backend/src/Database.csproj @@ -51,6 +51,7 @@ + diff --git a/backend/src/GraphQl/AppliedMethodInput.cs b/backend/src/GraphQl/AppliedMethodInput.cs index 2104e04a..34c58d4e 100644 --- a/backend/src/GraphQl/AppliedMethodInput.cs +++ b/backend/src/GraphQl/AppliedMethodInput.cs @@ -3,7 +3,7 @@ namespace Database.GraphQl { - public record AppliedMethodInput( + public sealed record AppliedMethodInput( Guid MethodId, IReadOnlyList Arguments, IReadOnlyList Sources diff --git a/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataInput.cs b/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataInput.cs index ca76e285..5de111aa 100644 --- a/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataInput.cs +++ b/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataInput.cs @@ -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>] string Locale, diff --git a/backend/src/GraphQl/CielabColorInput.cs b/backend/src/GraphQl/CielabColorInput.cs index b4e05998..93bafb4c 100644 --- a/backend/src/GraphQl/CielabColorInput.cs +++ b/backend/src/GraphQl/CielabColorInput.cs @@ -1,6 +1,6 @@ namespace Database.GraphQl { - public record CielabColorInput( + public sealed record CielabColorInput( double LStar, double AStar, double BStar diff --git a/backend/src/GraphQl/Common/OpenEndedDateTimeRangeInput.cs b/backend/src/GraphQl/Common/OpenEndedDateTimeRangeInput.cs index c7b0e746..2f22341e 100644 --- a/backend/src/GraphQl/Common/OpenEndedDateTimeRangeInput.cs +++ b/backend/src/GraphQl/Common/OpenEndedDateTimeRangeInput.cs @@ -2,7 +2,7 @@ namespace Database.GraphQl.Common { - public record OpenEndedDateTimeRangeInput( + public sealed record OpenEndedDateTimeRangeInput( DateTime? From, DateTime? Until ); diff --git a/backend/src/GraphQl/CrossDatabaseDataReferenceInput.cs b/backend/src/GraphQl/CrossDatabaseDataReferenceInput.cs index 22e47a27..4f986eb9 100644 --- a/backend/src/GraphQl/CrossDatabaseDataReferenceInput.cs +++ b/backend/src/GraphQl/CrossDatabaseDataReferenceInput.cs @@ -3,7 +3,7 @@ namespace Database.GraphQl { - public record CrossDatabaseDataReferenceInput( + public sealed record CrossDatabaseDataReferenceInput( Guid DataId, DateTime DataTimestamp, DataKind DataKind, diff --git a/backend/src/GraphQl/DataApprovalInput.cs b/backend/src/GraphQl/DataApprovalInput.cs index 4d6f8a92..f8110e45 100644 --- a/backend/src/GraphQl/DataApprovalInput.cs +++ b/backend/src/GraphQl/DataApprovalInput.cs @@ -2,7 +2,7 @@ namespace Database.GraphQl { - public record DataApprovalInput( + public sealed record DataApprovalInput( DateTime Timestamp, string Signature, string KeyFingerprint, diff --git a/backend/src/GraphQl/Databases/DatabaseMutations.cs b/backend/src/GraphQl/Databases/DatabaseMutations.cs index 314a0181..0b35b505 100644 --- a/backend/src/GraphQl/Databases/DatabaseMutations.cs +++ b/backend/src/GraphQl/Databases/DatabaseMutations.cs @@ -19,10 +19,10 @@ public async Task UpdateDatabaseAsync( CancellationToken cancellationToken ) { - return (await QueryingMetabase.QueryMetabase( + return (await Metabase.QueryingMetabase.QueryGraphQl( appSettings, new GraphQL.GraphQLRequest( - query: await QueryingMetabase.ConstructQuery( + query: await Metabase.QueryingMetabase.ConstructGraphQlQuery( new[] { "UpdateDatabase.graphql" } diff --git a/backend/src/GraphQl/Databases/DatabaseQueries.cs b/backend/src/GraphQl/Databases/DatabaseQueries.cs index c8b18bc0..f9da5124 100644 --- a/backend/src/GraphQl/Databases/DatabaseQueries.cs +++ b/backend/src/GraphQl/Databases/DatabaseQueries.cs @@ -1,9 +1,12 @@ using System; +using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using HotChocolate; using HotChocolate.Execution; +using HotChocolate.Resolvers; using HotChocolate.Types; using Microsoft.AspNetCore.Http; @@ -21,72 +24,102 @@ public async Task GetDatabaseAsync( [Service] AppSettings appSettings, [Service] IHttpClientFactory httpClientFactory, [Service] IHttpContextAccessor httpContextAccessor, + IResolverContext resolverContext, CancellationToken cancellationToken ) { - var response = - await QueryingMetabase.QueryMetabase( - appSettings, - new GraphQL.GraphQLRequest( - query: await QueryingMetabase.ConstructQuery( - new[] { - "Databases.graphql" - } - ).ConfigureAwait(false), - variables: new - { - where = new + try + { + var response = + await Metabase.QueryingMetabase.QueryGraphQl( + appSettings, + new GraphQL.GraphQLRequest( + query: await Metabase.QueryingMetabase.ConstructGraphQlQuery( + new[] { + "Databases.graphql" + } + ).ConfigureAwait(false), + variables: new { - locator = new + where = new { - // TODO This is error-prone. - eq = new Uri(new Uri(appSettings.Host), "/graphql") + locator = new + { + // TODO This is error-prone. + eq = new Uri(new Uri(appSettings.Host), "/graphql") + } } - } - }, - operationName: "Databases" - ), - httpClientFactory, - httpContextAccessor, - cancellationToken - ).ConfigureAwait(false); - if (response is null) - { - throw new QueryException( - ErrorBuilder.New() - .SetCode("NULL_RESPONSE") - .SetMessage("Response is null.") - .Build() - ); - } - if (response.Data.Databases.Nodes is null) - { - throw new QueryException( - ErrorBuilder.New() - .SetCode("NULL_NODES") - .SetMessage("The supposed list of databases is null.") - .Build() - ); + }, + operationName: "Databases" + ), + httpClientFactory, + httpContextAccessor, + cancellationToken + ).ConfigureAwait(false); + if (response is null) + { + throw new QueryException( + ErrorBuilder.New() + .SetCode("NULL_RESPONSE") + .SetPath(resolverContext.Path) + .SetMessage("Response is null.") + .Build() + ); + } + if (response.Data.Databases.Nodes is null) + { + throw new QueryException( + ErrorBuilder.New() + .SetCode("NULL_NODES") + .SetPath(resolverContext.Path) + .SetMessage("The supposed list of databases is null.") + .Build() + ); + } + if (response.Data.Databases.Nodes.Count == 0) + { + throw new QueryException( + ErrorBuilder.New() + .SetCode("NO_DATABASE") + .SetPath(resolverContext.Path) + .SetMessage("The list of databases is empty.") + .Build() + ); + } + if (response.Data.Databases.Nodes.Count >= 2) + { + throw new QueryException( + ErrorBuilder.New() + .SetCode("AMBIGUOUS_DATABASE") + .SetPath(resolverContext.Path) + .SetMessage("The list of databases has more than one entry.") + .Build() + ); + } + return response.Data.Databases.Nodes[0]; } - if (response.Data.Databases.Nodes.Count == 0) + catch (HttpRequestException e) { throw new QueryException( ErrorBuilder.New() - .SetCode("NO_DATABASE") - .SetMessage("The list of databases is empty.") + .SetCode("METABASE_REQUEST_FAILED") + .SetPath(resolverContext.Path) + .SetMessage($"Failed with status code {e.StatusCode} to request the metabase GraphQl endpoint.") + .SetException(e) .Build() ); } - if (response.Data.Databases.Nodes.Count >= 2) + catch (JsonException e) { throw new QueryException( ErrorBuilder.New() - .SetCode("AMBIGUOUS_DATABASE") - .SetMessage("The list of databases has more than one entry.") + .SetCode("JSON_DESERIALIZATION_FAILED") + .SetPath(resolverContext.Path.ToList().Concat(e.Path?.Split('.') ?? Array.Empty()).ToList()) // TODO Splitting the path at '.' is wrong in general. + .SetMessage($"Failed to deserialize GraphQL response of request to the metabase GraphQl endpoint. The details given are: Zero-based number of bytes read within the current line before the exception are {e.BytePositionInLine}, zero-based number of lines read before the exception are {e.LineNumber}, message that describes the current exception is '{e.Message}', path within the JSON where the exception was encountered is {e.Path}.") + .SetException(e) .Build() ); } - return response.Data.Databases.Nodes[0]; } } } diff --git a/backend/src/GraphQl/Databases/QueryingMetabase.cs b/backend/src/GraphQl/Databases/QueryingMetabase.cs deleted file mode 100644 index cad0a425..00000000 --- a/backend/src/GraphQl/Databases/QueryingMetabase.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using GraphQL.Client.Serializer.SystemTextJson; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Http; -using OpenIddict.Client.AspNetCore; - -namespace Database.GraphQl.Databases -{ - public sealed class QueryingMetabase - { - private static readonly JsonSerializerOptions SerializerOptions = - new() - { - Converters = { new JsonStringEnumConverter(new ConstantCaseJsonNamingPolicy(), false), }, - NumberHandling = JsonNumberHandling.Strict, - PropertyNameCaseInsensitive = false, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReadCommentHandling = JsonCommentHandling.Disallow, - IncludeFields = false, - IgnoreReadOnlyProperties = false, - IgnoreReadOnlyFields = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; //.SetupImmutableConverter(); - - public static async Task ConstructQuery( - string[] fileNames - ) - { - return string.Join( - Environment.NewLine, - await Task.WhenAll( - fileNames.Select(fileName => - File.ReadAllTextAsync($"GraphQl/Databases/Queries/{fileName}") - ) - ).ConfigureAwait(false) - ); - } - - public static async - Task> - QueryMetabase( - AppSettings appSettings, - GraphQL.GraphQLRequest request, - IHttpClientFactory httpClientFactory, - IHttpContextAccessor httpContextAccessor, - CancellationToken cancellationToken - ) - where TGraphQlResponse : class - { - using var httpClient = httpClientFactory.CreateClient(); - string? bearerToken = null; - if (httpContextAccessor.HttpContext is not null) - { - bearerToken = await httpContextAccessor.HttpContext.GetTokenAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken - ).ConfigureAwait(false); - } - // For some reason `httpClient.PostAsJsonAsync` without `MakeJsonHttpContent` but with `SerializerOptions` results in `BadRequest` status code. It has to do with `JsonContent.Create` used within `PostAsJsonAsync` --- we also cannot use `JsonContent.Create` in `MakeJsonHttpContent`. What is happening here? - using var jsonHttpContent = MakeJsonHttpContent(request); - using var httpRequestMessage = new HttpRequestMessage( - HttpMethod.Post, - // TODO Consider using [Flurl](https://flurl.dev) to construct URIs. For the pitfalls of using `Uri` as below see the comments to https://stackoverflow.com/questions/372865/path-combine-for-urls/1527643#1527643 - new Uri(new Uri(appSettings.MetabaseHost), "/graphql/") - ); - httpRequestMessage.Content = jsonHttpContent; - httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); - using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); - if (httpResponseMessage.StatusCode != HttpStatusCode.OK) - { - throw new HttpRequestException($"The status code is not {HttpStatusCode.OK}.", null, httpResponseMessage.StatusCode); - } - // We could use `httpResponseMessage.Content.ReadFromJsonAsync>` which would make debugging more difficult though, https://docs.microsoft.com/en-us/dotnet/api/system.net.http.json.httpcontentjsonextensions.readfromjsonasync?view=net-5.0#System_Net_Http_Json_HttpContentJsonExtensions_ReadFromJsonAsync__1_System_Net_Http_HttpContent_System_Text_Json_JsonSerializerOptions_System_Threading_CancellationToken_ - using var graphQlResponseStream = - await httpResponseMessage.Content - .ReadAsStreamAsync(cancellationToken) - .ConfigureAwait(false); - // Console.WriteLine("aaaaaaaaaaaaaa"); - // Console.WriteLine(new StreamReader(graphQlResponseStream).ReadToEnd()); - // {"data":{"databases":{"nodes":[{"uuid":"70fe99ed-f212-4666-adde-6e0877f3e518","name":"A","description":"B","locator":"https://local.solarbuildingenvelopes.com:5051/graphql","verificationState":"VERIFIED","verificationCode":"","canCurrentUserUpdateNode":false,"canCurrentUserVerifyNode":false},{"uuid":"6313a485-3767-4352-b75c-2a8e431cc4a9","name":"X","description":"Y","locator":"https://local.solarbuildingenvelopes.com:5051/graphql","verificationState":"VERIFIED","verificationCode":"njckM853NWOJnH\u002BXyDXIQMwEdHteu9JIlQVgGp97ewZOTYzx3sVip3HcTRS9QfWdBUPnYAeKZg76BbTMEUwMZQ==","canCurrentUserUpdateNode":false,"canCurrentUserVerifyNode":false}]}}} - var deserializedGraphQlResponse = - await JsonSerializer.DeserializeAsync>( - graphQlResponseStream, - SerializerOptions, - cancellationToken - ).ConfigureAwait(false); - if (deserializedGraphQlResponse is null) - { - throw new JsonException("Failed to deserialize the GraphQL response."); - } - return deserializedGraphQlResponse; - } - - private static HttpContent MakeJsonHttpContent( - TContent content - ) - { - // For some reason using `JsonContent.Create(content, null, SerializerOptions)` results in status code `BadRequest`. - var result = - new ByteArrayContent( - JsonSerializer.SerializeToUtf8Bytes( - content, - SerializerOptions - ) - ); - result.Headers.ContentType = - new MediaTypeHeaderValue("application/json"); - return result; - } - } -} \ No newline at end of file diff --git a/backend/src/GraphQl/Databases/UpdateDatabaseInput.cs b/backend/src/GraphQl/Databases/UpdateDatabaseInput.cs index 111fe98a..246041a1 100644 --- a/backend/src/GraphQl/Databases/UpdateDatabaseInput.cs +++ b/backend/src/GraphQl/Databases/UpdateDatabaseInput.cs @@ -2,7 +2,7 @@ namespace Database.GraphQl.Databases { - public record UpdateDatabaseInput( + public sealed record UpdateDatabaseInput( Guid DatabaseId, string Name, string Description, diff --git a/backend/src/GraphQl/FileMetaInformationInput.cs b/backend/src/GraphQl/FileMetaInformationInput.cs index 4957734f..80633e80 100644 --- a/backend/src/GraphQl/FileMetaInformationInput.cs +++ b/backend/src/GraphQl/FileMetaInformationInput.cs @@ -2,7 +2,7 @@ namespace Database.GraphQl { - public record FileMetaInformationInput( + public sealed record FileMetaInformationInput( string[] Path, Guid DataFormatId ); diff --git a/backend/src/GraphQl/GetHttpsResources/CreateGetHttpsResourceInput.cs b/backend/src/GraphQl/GetHttpsResources/CreateGetHttpsResourceInput.cs index 654b64ce..96b2b166 100644 --- a/backend/src/GraphQl/GetHttpsResources/CreateGetHttpsResourceInput.cs +++ b/backend/src/GraphQl/GetHttpsResources/CreateGetHttpsResourceInput.cs @@ -3,7 +3,7 @@ namespace Database.GraphQl.GetHttpsResources { - public record CreateGetHttpsResourceInput( + public sealed record CreateGetHttpsResourceInput( string AccessToken, string Description, string HashValue, diff --git a/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataInput.cs b/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataInput.cs index a50c4a2f..ef980885 100644 --- a/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataInput.cs +++ b/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataInput.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.HygrothermalDataX { - public record CreateHygrothermalDataInput( + public sealed record CreateHygrothermalDataInput( string AccessToken, // TODO Why does specifying the type with an attribute not work here? [GraphQLType>] string Locale, diff --git a/backend/src/GraphQl/NamedMethodArgumentInput.cs b/backend/src/GraphQl/NamedMethodArgumentInput.cs index a3ae1af7..bb97c46b 100644 --- a/backend/src/GraphQl/NamedMethodArgumentInput.cs +++ b/backend/src/GraphQl/NamedMethodArgumentInput.cs @@ -2,7 +2,7 @@ namespace Database.GraphQl { - public record NamedMethodArgumentInput( + public sealed record NamedMethodArgumentInput( string Name, JsonElement Value ); diff --git a/backend/src/GraphQl/NamedMethodSourceInput.cs b/backend/src/GraphQl/NamedMethodSourceInput.cs index bc802f1d..30ba9faf 100644 --- a/backend/src/GraphQl/NamedMethodSourceInput.cs +++ b/backend/src/GraphQl/NamedMethodSourceInput.cs @@ -1,6 +1,6 @@ namespace Database.GraphQl { - public record NamedMethodSourceInput( + public sealed record NamedMethodSourceInput( string Name, CrossDatabaseDataReferenceInput Value ); diff --git a/backend/src/GraphQl/OpticalDataX/CreateOpticalDataInput.cs b/backend/src/GraphQl/OpticalDataX/CreateOpticalDataInput.cs index c0525343..41fb8586 100644 --- a/backend/src/GraphQl/OpticalDataX/CreateOpticalDataInput.cs +++ b/backend/src/GraphQl/OpticalDataX/CreateOpticalDataInput.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.OpticalDataX { - public record CreateOpticalDataInput( + public sealed record CreateOpticalDataInput( string AccessToken, // TODO Why does specifying the type with an attribute not work here? [GraphQLType>] string Locale, diff --git a/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataInput.cs b/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataInput.cs index 8ae7499f..da1322c4 100644 --- a/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataInput.cs +++ b/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataInput.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.PhotovoltaicDataX { - public record CreatePhotovoltaicDataInput( + public sealed record CreatePhotovoltaicDataInput( string AccessToken, // TODO Why does specifying the type with an attribute not work here? [GraphQLType>] string Locale, diff --git a/backend/src/GraphQl/RootGetHttpsResourceInput.cs b/backend/src/GraphQl/RootGetHttpsResourceInput.cs index 5075fc5c..808c3e3c 100644 --- a/backend/src/GraphQl/RootGetHttpsResourceInput.cs +++ b/backend/src/GraphQl/RootGetHttpsResourceInput.cs @@ -3,7 +3,7 @@ namespace Database.GraphQl { - public record RootGetHttpsResourceInput( + public sealed record RootGetHttpsResourceInput( string Description, string HashValue, Guid DataFormatId, diff --git a/backend/src/GraphQl/ToTreeVertexAppliedConversionMethodInput.cs b/backend/src/GraphQl/ToTreeVertexAppliedConversionMethodInput.cs index 891040ea..d6ed0886 100644 --- a/backend/src/GraphQl/ToTreeVertexAppliedConversionMethodInput.cs +++ b/backend/src/GraphQl/ToTreeVertexAppliedConversionMethodInput.cs @@ -3,7 +3,7 @@ namespace Database.GraphQl { - public record ToTreeVertexAppliedConversionMethodInput( + public sealed record ToTreeVertexAppliedConversionMethodInput( Guid MethodId, IReadOnlyList Arguments, string SourceName diff --git a/backend/src/GraphQl/Users/UserInfo.cs b/backend/src/GraphQl/Users/UserInfo.cs new file mode 100644 index 00000000..79e7a139 --- /dev/null +++ b/backend/src/GraphQl/Users/UserInfo.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Database.GraphQl.Users +{ + public sealed record Address( + string Formatted + ); + + public sealed record UserInfo( + Address? Address, + string Email, + bool EmailVerified, + string Name, + string? PhoneNumber, + bool PhoneNumberVerified, + IReadOnlyList? Roles, + string Sub, // Subject + string? Website + ); +} \ No newline at end of file diff --git a/backend/src/GraphQl/Users/UserQueries.cs b/backend/src/GraphQl/Users/UserQueries.cs index 7bca0b8f..214f90fb 100644 --- a/backend/src/GraphQl/Users/UserQueries.cs +++ b/backend/src/GraphQl/Users/UserQueries.cs @@ -1,11 +1,24 @@ +using System; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using GraphQL.Client.Serializer.SystemTextJson; using HotChocolate; +using HotChocolate.Resolvers; using HotChocolate.Types; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using OpenIddict.Abstractions; +using OpenIddict.Client.AspNetCore; +using Yoh.Text.Json.NamingPolicies; namespace Database.GraphQl.Users { @@ -22,12 +35,56 @@ CancellationToken cancellationToken { return null; } - return await - context.Users.AsQueryable() + return + await context.Users.AsQueryable() .SingleOrDefaultAsync( u => u.Subject == claimsPrincipal.GetClaim(ClaimTypes.NameIdentifier), cancellationToken ).ConfigureAwait(false); } + + public async Task GetCurrentUserInfoAsync( + [Service] AppSettings appSettings, + [Service] IHttpClientFactory httpClientFactory, + [Service] IHttpContextAccessor httpContextAccessor, + IResolverContext resolverContext, + CancellationToken cancellationToken + ) + { + var uri = new Uri(new Uri(appSettings.MetabaseHost), "/connect/userinfo"); + try + { + return await Metabase.QueryingMetabase.QueryRest( + uri, + httpClientFactory, + httpContextAccessor, + cancellationToken + ); + } + catch (HttpRequestException e) + { + resolverContext.ReportError( + ErrorBuilder.New() + .SetCode("METABASE_REQUEST_FAILED") + .SetPath(resolverContext.Path) + .SetMessage($"Failed with status code {e.StatusCode} to request {uri}.") + .SetException(e) + .Build() + ); + return null; + } + catch (JsonException e) + { + resolverContext.ReportError( + ErrorBuilder.New() + .SetCode("JSON_DESERIALIZATION_FAILED") + .SetPath(resolverContext.Path.ToList().Concat(e.Path?.Split('.') ?? Array.Empty()).ToList()) // TODO Splitting the path at '.' is wrong in general. + .SetMessage($"Failed to deserialize GraphQL response of request to {uri}. The details given are: Zero-based number of bytes read within the current line before the exception are {e.BytePositionInLine}, zero-based number of lines read before the exception are {e.LineNumber}, message that describes the current exception is '{e.Message}', path within the JSON where the exception was encountered is {e.Path}.") + .SetException(e) + .Build() + ); + return null; + } + } } } \ No newline at end of file diff --git a/backend/src/GraphQl/Databases/Queries/Databases.graphql b/backend/src/Metabase/Queries/Databases.graphql similarity index 100% rename from backend/src/GraphQl/Databases/Queries/Databases.graphql rename to backend/src/Metabase/Queries/Databases.graphql diff --git a/backend/src/GraphQl/Databases/Queries/UpdateDatabase.graphql b/backend/src/Metabase/Queries/UpdateDatabase.graphql similarity index 100% rename from backend/src/GraphQl/Databases/Queries/UpdateDatabase.graphql rename to backend/src/Metabase/Queries/UpdateDatabase.graphql diff --git a/backend/src/Metabase/QueryingMetabase.cs b/backend/src/Metabase/QueryingMetabase.cs new file mode 100644 index 00000000..5d5f2770 --- /dev/null +++ b/backend/src/Metabase/QueryingMetabase.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using GraphQL.Client.Serializer.SystemTextJson; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using OpenIddict.Client.AspNetCore; +using Yoh.Text.Json.NamingPolicies; + +namespace Database.Metabase +{ + public sealed class QueryingMetabase + { + private static async Task ExtractBearerToken( + IHttpContextAccessor httpContextAccessor + ) + { + if (httpContextAccessor.HttpContext is null) + { + return null; + } + return await httpContextAccessor.HttpContext.GetTokenAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken + ).ConfigureAwait(false); + } + + private static readonly JsonSerializerOptions GraphQlSerializerOptions = + new() + { + Converters = { new JsonStringEnumConverter(new ConstantCaseJsonNamingPolicy(), false), }, + NumberHandling = JsonNumberHandling.Strict, + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Disallow, + IncludeFields = false, + IgnoreReadOnlyProperties = false, + IgnoreReadOnlyFields = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; //.SetupImmutableConverter(); + + public static async Task ConstructGraphQlQuery( + string[] fileNames + ) + { + return string.Join( + Environment.NewLine, + await Task.WhenAll( + fileNames.Select(fileName => + File.ReadAllTextAsync($"Metabase/Queries/{fileName}") + ) + ).ConfigureAwait(false) + ); + } + + public static Task> QueryGraphQl( + AppSettings appSettings, + GraphQL.GraphQLRequest request, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + CancellationToken cancellationToken + ) + where TGraphQlResponse : class + { + return Query>( + HttpMethod.Post, + // TODO Consider using [Flurl](https://flurl.dev) to construct URIs. For the pitfalls of using `Uri` as below see the comments to https://stackoverflow.com/questions/372865/path-combine-for-urls/1527643#1527643 + new Uri(new Uri(appSettings.MetabaseHost), "/graphql/"), + MakeJsonHttpContent(request), + GraphQlSerializerOptions, + httpClientFactory, + httpContextAccessor, + cancellationToken + ); + } + + private static HttpContent MakeJsonHttpContent( + TContent content + ) + { + // For some reason using `JsonContent.Create(content, null, SerializerOptions)` results in status code `BadRequest`. + var result = + new ByteArrayContent( + JsonSerializer.SerializeToUtf8Bytes( + content, + GraphQlSerializerOptions + ) + ); + result.Headers.ContentType = + new MediaTypeHeaderValue("application/json"); + return result; + } + + private static readonly JsonSerializerOptions RestSerializerOptions = + new() + { + Converters = { new JsonStringEnumConverter(new ConstantCaseJsonNamingPolicy(), false), }, + NumberHandling = JsonNumberHandling.Strict, + PropertyNameCaseInsensitive = false, + // TODO When we run .NET 8, remove [Yoh.Text.Json.NamingPolicies](https://github.com/YohDeadfall/Yoh.Text.Json.NamingPolicies) and use [.NET's inbuilt snake-case support](https://github.com/dotnet/runtime/pull/69613). + PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower, + ReadCommentHandling = JsonCommentHandling.Disallow, + IncludeFields = false, + IgnoreReadOnlyProperties = false, + IgnoreReadOnlyFields = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; //.SetupImmutableConverter(); + + public static Task QueryRest( + Uri uri, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + CancellationToken cancellationToken + ) + where TResponse : class + { + return Query( + HttpMethod.Get, + uri, + null, + RestSerializerOptions, + httpClientFactory, + httpContextAccessor, + cancellationToken + ); + } + + private static async Task Query( + HttpMethod httpMethod, + Uri uri, + HttpContent? httpContent, + JsonSerializerOptions serializerOptions, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + CancellationToken cancellationToken + ) + where TResponse : class + { + using var httpClient = httpClientFactory.CreateClient(); + var bearerToken = await ExtractBearerToken(httpContextAccessor).ConfigureAwait(false); + using var httpRequestMessage = new HttpRequestMessage( + httpMethod, + uri + ); + httpRequestMessage.Content = httpContent; + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + if (httpResponseMessage.StatusCode != HttpStatusCode.OK) + { + throw new HttpRequestException($"The status code is not {HttpStatusCode.OK} but {httpResponseMessage.StatusCode}.", null, httpResponseMessage.StatusCode); + } + // We could use `httpResponseMessage.Content.ReadFromJsonAsync>` which would make debugging more difficult though, https://docs.microsoft.com/en-us/dotnet/api/system.net.http.json.httpcontentjsonextensions.readfromjsonasync?view=net-5.0#System_Net_Http_Json_HttpContentJsonExtensions_ReadFromJsonAsync__1_System_Net_Http_HttpContent_System_Text_Json_JsonSerializerOptions_System_Threading_CancellationToken_ + using var responseStream = + await httpResponseMessage.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + // Console.WriteLine(new StreamReader(responseStream).ReadToEnd()); + var deserializedResponse = + await JsonSerializer.DeserializeAsync( + responseStream, + serializerOptions, + cancellationToken + ).ConfigureAwait(false); + if (deserializedResponse is null) + { + throw new JsonException("Failed to deserialize the GraphQL response."); + } + return deserializedResponse; + } + } +} \ No newline at end of file diff --git a/backend/src/Startup.cs b/backend/src/Startup.cs index d9b3d365..5ec6b6a7 100644 --- a/backend/src/Startup.cs +++ b/backend/src/Startup.cs @@ -24,6 +24,8 @@ namespace Database { public sealed class Startup { + private const string GraphQlCorsPolicy = "GraphQlCorsPolicy"; + private readonly IWebHostEnvironment _environment; private readonly AppSettings _appSettings; @@ -44,6 +46,10 @@ public void ConfigureServices(IServiceCollection services) ConfigureMessageSenderServices(services); ConfigureRequestResponseServices(services); ConfigureSessionServices(services); + services.AddAntiforgery(_ => + { + _.HeaderName = "X-XSRF-TOKEN"; + }); services .AddDataProtection() .PersistKeysToDbContext(); @@ -70,11 +76,13 @@ private static void ConfigureRequestResponseServices(IServiceCollection services } ); services.AddCors(_ => - _.AddDefaultPolicy(policy => - policy - .AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod() + _.AddPolicy( + name: GraphQlCorsPolicy, + policy => + policy + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod() ) ); services.AddControllersWithViews(); @@ -170,7 +178,7 @@ public void Configure(IApplicationBuilder app) // app.UseHttpsRedirection(); // Done by NGINX app.UseSerilogRequestLogging(); app.UseStaticFiles(); - app.UseCookiePolicy(); + app.UseCookiePolicy(); // [SameSite cookies](https://learn.microsoft.com/en-us/aspnet/core/security/samesite) app.UseRouting(); // TODO Do we really want this? See https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-5.0 app.UseRequestLocalization(_ => @@ -189,7 +197,8 @@ public void Configure(IApplicationBuilder app) /* app.UseWebSockets(); */ app.UseEndpoints(_ => { - _.MapGraphQL().WithOptions( + _.MapGraphQL() + .WithOptions( // https://chillicream.com/docs/hotchocolate/server/middleware new GraphQLServerOptions { @@ -206,7 +215,8 @@ public void Configure(IApplicationBuilder app) Title = "GraphQL" } } - ); + ) + .RequireCors(GraphQlCorsPolicy); _.MapControllers(); _.MapHealthChecks("/health", new HealthCheckOptions diff --git a/frontend/components/Layout.tsx b/frontend/components/Layout.tsx index cb8c2a98..213be874 100644 --- a/frontend/components/Layout.tsx +++ b/frontend/components/Layout.tsx @@ -1,5 +1,5 @@ import Head from "next/head"; -import { ReactNode, useEffect } from "react"; +import { ReactNode, useEffect, useState } from "react"; import Footer from "./Footer"; import NavBar from "./NavBar"; import { Modal, Layout as AntLayout, Typography } from "antd"; @@ -50,6 +50,7 @@ export default function Layout({ children }: LayoutProps) { const [cookies, setCookie] = useCookies([cookieConsentName]); const shouldShowCookieConsent = cookies[cookieConsentName] != cookieConsentValue; + const [loadedAntiforgeryToken, setLoadedAntiforgeryToken] = useState(false); useEffect(() => { if (shouldShowCookieConsent) { @@ -70,6 +71,16 @@ export default function Layout({ children }: LayoutProps) { } }, [shouldShowCookieConsent, setCookie]); + useEffect(() => { + fetch(paths.antiforgeryToken).then((_) => { + setLoadedAntiforgeryToken(true); + }); + }, []); + + if (!loadedAntiforgeryToken) { + return null; + } + return ( diff --git a/frontend/components/NavBar.tsx b/frontend/components/NavBar.tsx index 1a60c642..4afb82ff 100644 --- a/frontend/components/NavBar.tsx +++ b/frontend/components/NavBar.tsx @@ -5,6 +5,7 @@ import { Button, Menu } from "antd"; import { UserOutlined } from "@ant-design/icons"; import paths from "../paths"; import { useCurrentUserQuery } from "../queries/currentUser.graphql"; +import { getXsrfToken } from "../lib/apollo"; type NavItemProps = { path: string; @@ -16,6 +17,10 @@ export type NavBarProps = { }; const moderatorItems = [ + { + path: paths.userInfo, + label: "User Info", + }, { path: paths.createData, label: "Create Data", @@ -43,18 +48,27 @@ export default function NavBar({ items }: NavBarProps) { title={currentUser ? currentUser.name : null} icon={} > + {moderatorItems.map(({ path, label }) => ( + + {label} + + ))}
+
- {moderatorItems.map(({ path, label }) => ( - - {label} - - ))} ) : ( diff --git a/frontend/lib/apollo.ts b/frontend/lib/apollo.ts index b1da91ae..dddb6dcd 100644 --- a/frontend/lib/apollo.ts +++ b/frontend/lib/apollo.ts @@ -20,7 +20,35 @@ export type ResolverContext = { res?: ServerResponse; }; +function getCookie(name: string, cookies: string): string | null { + return ( + cookies + .split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => { + return cookie.substring(0, name.length + 1) === `${name}=`; + }) + .map((cookie) => { + return decodeURIComponent(cookie.substring(name.length + 1)); + })[0] || null + ); +} + +export function getXsrfToken(): string | null { + return getCookie("XSRF-TOKEN", document.cookie); +} + function createIsomorphLink(context: ResolverContext = {}) { + var headers: Record = {}; + headers["accept"] = "application/json"; + var cookie = context.req?.headers?.cookie; + if (cookie) { + headers["cookie"] = cookie; + } + if (typeof window !== "undefined") { + var antiforgeryToken = getXsrfToken(); + if (antiforgeryToken) headers["X-XSRF-TOKEN"] = antiforgeryToken; + } return createPersistedQueryLink({ sha256, useGETForHashedQueries: false, @@ -40,12 +68,7 @@ function createIsomorphLink(context: ResolverContext = {}) { : `${process.env.NEXT_PUBLIC_DATABASE_URL}/graphql/`, useGETForQueries: false, // Use `POST` for queries to avoid "414 Request-URI Too Large" errors credentials: "same-origin", - headers: context.req?.headers?.cookie - ? { - accept: "application/json", - cookie: context.req.headers.cookie, - } - : { accept: "application/json" }, + headers: headers, }) ); } diff --git a/frontend/package.json b/frontend/package.json index 4f515bad..b7b7852a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,6 @@ "dayjs": "^1.0", "graphql": "^16.0.1", "next": "^13.1.4", - "next-auth": "^4.18.8", "react": "^18.2.0", "react-cookie": "^4.0.3", "react-dom": "^18.2.0", diff --git a/frontend/pages/api/auth/[...nextauth].ts b/frontend/pages/api/auth/[...nextauth].ts deleted file mode 100644 index ceb3bb3c..00000000 --- a/frontend/pages/api/auth/[...nextauth].ts +++ /dev/null @@ -1,344 +0,0 @@ -import { NextApiHandler } from "next"; -import NextAuth from "next-auth"; - -// @ts-ignore: TODO Ask `@types/next-auth` to extend their types? For example, to allow the callback `session` getting a `token` instead of a `user` as second parameter. See https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth -const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options); -export default authHandler; - -interface ProviderJsonWebToken { - name: string; - email: string; - picture: number | null; - sub: string; -} - -interface StoredJsonWebToken { - refreshToken: string; - accessToken: string; - accessTokenExpires: number; - error?: string; - user: User; -} - -interface SessionJsonWebToken extends StoredJsonWebToken { - iat: number; - exp: number; -} - -// extends `SessionBase` from `@types/next-auth`, see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/461c4b4dd7783684c16dbf1e9ef28b33a5455026/types/next-auth/_utils.d.ts#L26 -interface Session { - user?: User; - accessToken?: string; - expires?: number; - error?: string; -} - -interface ProviderAccount { - provider: string; - type: string; - id: string; - accessToken: string; - accessTokenExpires: number | null; - refreshToken: string; - idToken: string; - access_token: string; - token_type: "Bearer"; - expires_in: number; - scope: string; - id_token: string; - refresh_token: string; -} - -// For the list of registered claim names like `sub`, `exp`, `aud`, `iss`, and -// `iat`, see https://tools.ietf.org/html/rfc7519#section-4.1 -// For the list of public claim names like `name`, `azp`, and `at_hash` -// see https://www.iana.org/assignments/jwt/jwt.xhtml -interface ProviderProfile { - sub: string; - name: string; - email: string; - oi_au_id: string; - azp: string; - at_hash: string; - oi_tkn_id: string; - aud: string; - exp: number; - iss: string; - iat: number; -} - -interface User { - id: string; - name: string; - email: string; -} - -/** - * Takes a token, and returns a new token with updated - * `accessToken` and `accessTokenExpires`. If an error occurs, - * returns the old token and an error property - * - * See https://next-auth.js.org/tutorials/refresh-token-rotation - */ -async function refreshAccessToken( - token: StoredJsonWebToken -): Promise { - try { - const url = - `${process.env.NEXT_PUBLIC_METABASE_URL}/connect/token?` + - new URLSearchParams({ - client_id: process.env.AUTH_CLIENT_ID || "", // TODO Throw an error if environment variable is missing! - client_secret: process.env.AUTH_CLIENT_SECRET || "", // TODO Throw an error if environment variable is missing! - grant_type: "refresh_token", - refresh_token: token.refreshToken, - }); - const response = await fetch(url, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - method: "POST", - }); - const refreshedTokens = await response.json(); - if (!response.ok) { - throw refreshedTokens; - } - return { - ...token, - accessToken: refreshedTokens.access_token, - accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, - refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token - }; - } catch (error) { - console.log(error); - return { - ...token, - error: "RefreshAccessTokenError", // TODO Use global constant instead of string! - }; - } -} - -// https://next-auth.js.org/configuration/options -const options = { - // database: { - // type: "sqlite", - // database: ":memory:", - // synchronize: true, - // }, - // database: { - // type: "postgres", - // host: "database", - // port: 5432, - // username: "postgres", - // password: "postgres", - // database: "xbase_development", - // entityPrefix: "nextauth_", - // synchronize: process.env.NODE_ENV !== "production", - // // uri: - // // process.env.NODE_ENV === "production" - // // ? "Host=database; Port=5432; Database=xbase; User Id=postgres; Passfile=/run/secrets/pgpass;" - // // : "Host=database; Port=5432; Database=xbase_development; User Id=postgres; Password=postgres;", - // }, - providers: [ - { - // https://next-auth.js.org/configuration/providers#using-a-custom-provider - // https://next-auth.js.org/configuration/providers#oauth-provider-options - // https://buildingenvelopedata.org/.well-known/openid-configuration - id: "metabase", - name: "Metabase", - type: "oauth", - version: "2.0", - scope: "openid email profile roles api:read api:write offline_access", - params: { grant_type: "authorization_code" }, - authorizationParams: {}, - - // Note that the host of `authorizationUrl`, `accessTokenUrl`, and - // `requestTokenUrl` must be the same (supposedly for security). And that - // `authorizationUrl` must be a valid URL outside of the Docker network - // (as it is requested within the web browser). That is why we cannot use - // http://backend:8080/connect/token as `accessTokenUrl` (although it is - // the correct URL within the Docker network and only used within that - // network from the `frontend` service). - accessTokenUrl: `${process.env.NEXT_PUBLIC_METABASE_URL}/connect/token`, - requestTokenUrl: `${process.env.NEXT_PUBLIC_METABASE_URL}/connect/authorize`, - authorizationUrl: `${process.env.NEXT_PUBLIC_METABASE_URL}/connect/authorize?response_type=code`, - - profileUrl: `${process.env.NEXT_PUBLIC_METABASE_URL}/connect/userinfo`, - protection: "pkce", // https://security.stackexchange.com/questions/214980/does-pkce-replace-state-in-the-authorization-code-oauth-flow/215027#215027 - idToken: true, - headers: {}, - async profile(profile: ProviderProfile): Promise { - // You can use the tokens, in case you want to fetch more profile information - // For example several OAuth provider does not return e-mail by default. - // Depending on your provider, will have tokens like `access_token`, `id_token` and or `refresh_token` - return { - id: profile.sub, - name: profile.name, - email: profile.email, - }; - }, - clientId: process.env.AUTH_CLIENT_ID, - clientSecret: process.env.AUTH_CLIENT_SECRET, - }, - ], - secret: process.env.AUTH_SECRET, - session: { - // Use JSON Web Tokens for session instead of database sessions. - // This option can be used with or without a database for users/accounts. - // Note: `jwt` is automatically set to `true` if no database is specified. - jwt: true, - - // Seconds - How long until an idle session expires and is no longer valid. - maxAge: 30 * 24 * 60 * 60, // 30 days - - // Seconds - Throttle how frequently to write to database to extend a session. - // Use it to limit write operations. Set to 0 to always update the database. - // Note: This option is ignored if using JSON Web Tokens - // updateAge: 24 * 60 * 60, // 24 hours - }, - jwt: { - // A secret to use for key generation - you should set this explicitly - // Defaults to NextAuth.js secret if not explicitly specified. - secret: process.env.AUTH_JWT_SECRET, - - // Set to true to use encryption. Defaults to false (signing only). - encryption: true, - - // You can define your own encode/decode functions for signing and encryption - // if you want to override the default behaviour. - // async encode({ secret, token, maxAge }) {}, - // async decode({ secret, token, maxAge }) {}, - }, - debug: process.env.NODE_ENV !== "production", - theme: "auto", - callbacks: { - // See https://next-auth.js.org/tutorials/refresh-token-rotation - /** - * This JSON Web Token callback is called whenever a JSON Web Token is - * created (i.e. at sign in) or updated (i.e whenever a session is accessed - * in the client). - * - * @param {object} token Decrypted JSON Web Token - * @param {object} user User object (only available on sign in) - * @param {object} account Provider account (only available on sign in) - * @param {object} profile Provider profile (only available on sign in) - * @param {boolean} isNewUser True if new user (only available on sign in) - * @return {object} JSON Web Token that will be saved - */ - async jwt( - token: ProviderJsonWebToken | StoredJsonWebToken, - user: User | undefined, - account: ProviderAccount | undefined, - _profile: ProviderProfile | undefined, - _isNewUser: boolean | undefined - ): Promise { - // Initial sign in - if (user && account) { - return { - accessToken: account.accessToken, - accessTokenExpires: Date.now() + account.expires_in * 1000, - refreshToken: "", // account.refreshToken, // TODO When `accessToken` and `refreshToken` are set, then signing in does not work, there just is no session after signing in. Why? I first thought that it is a problem with the cookie size but it's not because the problem persists when I switch to a database for session storage. - user, - }; - } - // Type Guard: https://basarat.gitbook.io/typescript/type-system/typeguard#in - if ("accessToken" in token) { - if (Date.now() < token.accessTokenExpires) { - // Return previous token if the access token has not expired yet - return token; - } - // Access token has expired, try to update it - return refreshAccessToken(token); - } - throw new Error("Impossible!"); - }, - - /** - * The session callback is called whenever a session is checked. By - * default, only a subset of the token is returned for increased security. - * If you want to make something available you added to the token through - * the jwt() callback, you have to explicitely forward it here to make it - * available to the client. - * - * @param {object} session Session object - * @param {object} token User object (if using database sessions) - * JSON Web Token (if not using database sessions) - * @return {object} Session that will be returned to the client - */ - async session( - _session: { - user: { name?: string; email?: string }; - accessToken?: string; - expires: number; - }, - token: SessionJsonWebToken - ): Promise { - if (token) { - return { - user: token.user, - accessToken: token.accessToken, - expires: token.accessTokenExpires, - error: token.error, - }; - } - return {}; - }, - - /** - * Use the signIn() callback to control if a user is allowed to sign in. - * - * @param {object} user User object - * @param {object} account Provider account - * @param {object} profile Provider profile - * @return {boolean|string} Return `true` to allow sign in - * Return `false` to deny access - * Return `string` to redirect to (eg.: "/unauthorized") - */ - // async signIn( - // user: User, - // account: ProviderAccount, - // profile: ProviderProfile - // ): Promise { - // const isAllowedToSignIn = true; - // if (isAllowedToSignIn) { - // return true; - // } else { - // // Return false to display a default error message - // return false; - // // Or you can return a URL to redirect to: - // // return '/unauthorized' - // } - // }, - - /** - * The redirect callback is called anytime the user is redirected to a callback URL (e.g. on signin or signout). - * - * By default only URLs on the same URL as the site are allowed, you can use the redirect callback to customise that behaviour. - * @param {string} url URL provided as callback URL by the client - * @param {string} baseUrl Default base URL of site (can be used as fallback) - * @return {string} URL the client will be redirect to - */ - // async redirect(url: string, baseUrl: string) { - // return url.startsWith(baseUrl) ? url : baseUrl; - // }, - }, - // pages: { - // signIn: '/auth/signin', - // signOut: '/auth/signout', - // error: '/auth/error', // Error code passed in query string as ?error= - // verifyRequest: '/auth/verify-request', // (used for check email message) - // newUser: null // If set, new users will be directed here on first sign in - // }, - // events: { - // async signIn(message) { /* on successful sign in */ }, - // async signOut(message) { /* on signout */ }, - // async createUser(message) { /* user created */ }, - // async linkAccount(message) { /* account linked to a user */ }, - // async session(message) { /* session is active */ }, - // async error(message) { /* error in authentication flow */ }, - // }, - // logger: { - // error(code, ...message) { log.error(code, message) }, - // warn(code, ...message) { log.warn(code, message) }, - // debug(code, ...message) { log.debug(code, message) }, - // }, -}; diff --git a/frontend/pages/user-info.tsx b/frontend/pages/user-info.tsx new file mode 100644 index 00000000..50ba0030 --- /dev/null +++ b/frontend/pages/user-info.tsx @@ -0,0 +1,81 @@ +import Layout from "../components/Layout"; +import { useCurrentUserInfoQuery } from "../queries/currentUser.graphql"; +import { Skeleton, Result, Descriptions, Typography } from "antd"; +import { PageHeader } from "@ant-design/pro-layout"; +import { useEffect } from "react"; +import { messageApolloError } from "../lib/apollo"; + +function Page() { + const { loading, error, data } = useCurrentUserInfoQuery(); + const currentUserInfo = data?.currentUserInfo; + + useEffect(() => { + if (error) { + messageApolloError(error); + } + }, [error]); + + if (loading) { + return ; + } + + if (!currentUserInfo) { + return ( + + ); + } + + return ( + + + + {currentUserInfo.email && ( + + {currentUserInfo.email} ( + {currentUserInfo.emailVerified === true + ? "Verified" + : "Unverified"} + ) + + )} + {currentUserInfo.phoneNumber && ( + + {currentUserInfo.phoneNumber} ( + {currentUserInfo.phoneNumberVerified === true + ? "Verified" + : "Unverified"} + ) + + )} + {currentUserInfo.address && ( + + {currentUserInfo.address.formatted} + + )} + {currentUserInfo.website && ( + + + {currentUserInfo.website} + + + )} + {currentUserInfo.roles && ( + + {currentUserInfo.roles} + + )} + + + + ); +} + +export default Page; diff --git a/frontend/paths.ts b/frontend/paths.ts index d3a93f8e..50c4af48 100644 --- a/frontend/paths.ts +++ b/frontend/paths.ts @@ -2,6 +2,8 @@ export default { home: "/", legalNotice: "/legal-notice", dataProtectionInformation: "/data-protection-information", + antiforgeryToken: "/antiforgery/token", + userInfo: "/user-info", database: "/database", data: "/data", calorimetricData: "/data/calorimetric", diff --git a/frontend/queries/currentUser.graphql b/frontend/queries/currentUser.graphql index 7b06159f..b08beab4 100644 --- a/frontend/queries/currentUser.graphql +++ b/frontend/queries/currentUser.graphql @@ -1,5 +1,4 @@ fragment CurrentUserPartial on User { - email name } @@ -8,3 +7,19 @@ query CurrentUser { ...CurrentUserPartial } } + +query CurrentUserInfo { + currentUserInfo { + address { + formatted + } + email + emailVerified + name + phoneNumber + phoneNumberVerified + roles + sub + website + } +} diff --git a/frontend/type-defs.graphqls b/frontend/type-defs.graphqls index 6a723473..e937a4db 100644 --- a/frontend/type-defs.graphqls +++ b/frontend/type-defs.graphqls @@ -24,6 +24,10 @@ interface Node { id: ID! } +type Address { + formatted: String! +} + type AppliedMethod { arguments: [NamedMethodArgument!]! methodId: Uuid! @@ -400,6 +404,7 @@ type Query { allPhotovoltaicData("Returns the elements in the list that come after the specified cursor." after: String "Returns the elements in the list that come before the specified cursor." before: String "Returns the first _n_ elements from the list." first: Int "Returns the last _n_ elements from the list." last: Int locale: Locale order: [PhotovoltaicDataSortInput!] timestamp: DateTime where: PhotovoltaicDataPropositionInput): PhotovoltaicDataConnection calorimetricData(id: Uuid! locale: Locale timestamp: DateTime): CalorimetricData currentUser: User + currentUserInfo: UserInfo data(id: Uuid! locale: Locale timestamp: DateTime): Data database: Database! getHttpsResource(id: Uuid!): GetHttpsResource @@ -433,13 +438,24 @@ type UpdateDatabasePayload { } type User implements Node { - email: String! id: ID! name: String! subject: String! uuid: Uuid! } +type UserInfo { + address: Address + email: String! + emailVerified: Boolean! + name: String! + phoneNumber: String + phoneNumberVerified: Boolean! + roles: [String!] + sub: String! + website: String +} + input AppliedMethodInput { arguments: [NamedMethodArgumentInput!]! methodId: Uuid! diff --git a/frontend/yarn.lock b/frontend/yarn.lock index f348657e..6dda954c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -790,7 +790,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== @@ -1871,11 +1871,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@panva/hkdf@^1.0.2": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.0.4.tgz#4e02bb248402ff6c5c024e23a68438e2b0e69d67" - integrity sha512-003xWiCuvePbLaPHT+CRuaV4GlyCAVm6XYSbBZDHoWZGn1mNkVKFaDbGJjjxmEFvizUwlCoM6O18FCBMMky2zQ== - "@parcel/watcher@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.1.0.tgz#5f32969362db4893922c526a842d8af7a8538545" @@ -3109,11 +3104,6 @@ cookie@^0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== -cookie@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - copy-to-clipboard@^3.2.0: version "3.3.3" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" @@ -5102,7 +5092,7 @@ jiti@^1.17.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.18.2.tgz#80c3ef3d486ebf2450d9335122b32d121f2a83cd" integrity sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg== -jose@^4.10.0, jose@^4.11.4: +jose@^4.11.4: version "4.13.1" resolved "https://registry.yarnpkg.com/jose/-/jose-4.13.1.tgz#449111bb5ab171db85c03f1bd2cb1647ca06db1c" integrity sha512-MSJQC5vXco5Br38mzaQKiq9mwt7lwj2eXpgpRyQYNHYt2lq1PjkWa7DLXX0WVcQLE9HhMh3jPiufS7fhJf+CLQ== @@ -5484,21 +5474,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -next-auth@^4.18.8: - version "4.22.0" - resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.22.0.tgz#325982b8efaaa55f505ee104a382c3118fec9d96" - integrity sha512-08+kjnDoE7aQ52O996x6cwA3ffc2CbHIkrCgLYhbE+aDIJBKI0oA9UbIEIe19/+ODYJgpAHHOtJx4izmsgaVag== - dependencies: - "@babel/runtime" "^7.20.13" - "@panva/hkdf" "^1.0.2" - cookie "^0.5.0" - jose "^4.11.4" - oauth "^0.9.15" - openid-client "^5.4.0" - preact "^10.6.3" - preact-render-to-string "^5.1.19" - uuid "^8.3.2" - next@^13.1.4: version "13.3.0" resolved "https://registry.yarnpkg.com/next/-/next-13.3.0.tgz#40632d303d74fc8521faa0a5bf4a033a392749b1" @@ -5610,21 +5585,11 @@ nullthrows@^1.1.1: resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== -oauth@^0.9.15: - version "0.9.15" - resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" - integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA== - object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-hash@^2.0.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" - integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== - object-inspect@^1.12.3, object-inspect@^1.9.0: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" @@ -5688,11 +5653,6 @@ object.values@^1.1.6: define-properties "^1.1.4" es-abstract "^1.20.4" -oidc-token-hash@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.2.tgz#f9ca7f7f1f92d721a2973e66b7430cb52a486648" - integrity sha512-U91Ba78GtVBxcExLI7U+hC2AwJQqXQEW/D3fjmJC4hhSVIgdl954KO4Gu95WqAlgDKJdLATxkmuxraWLT0fVRQ== - omit.js@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/omit.js/-/omit.js-2.0.2.tgz#dd9b8436fab947a5f3ff214cb2538631e313ec2f" @@ -5721,16 +5681,6 @@ open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openid-client@^5.4.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.4.0.tgz#77f1cda14e2911446f16ea3f455fc7c405103eac" - integrity sha512-hgJa2aQKcM2hn3eyVtN12tEA45ECjTJPXCgUh5YzTzy9qwapCvmDTVPWOcWVL0d34zeQoQ/hbG9lJhl3AYxJlQ== - dependencies: - jose "^4.10.0" - lru-cache "^6.0.0" - object-hash "^2.0.1" - oidc-token-hash "^5.0.1" - optimism@^0.16.2: version "0.16.2" resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.16.2.tgz#519b0c78b3b30954baed0defe5143de7776bf081" @@ -5947,18 +5897,6 @@ postcss@8.4.14: picocolors "^1.0.0" source-map-js "^1.0.2" -preact-render-to-string@^5.1.19: - version "5.2.6" - resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz#0ff0c86cd118d30affb825193f18e92bd59d0604" - integrity sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw== - dependencies: - pretty-format "^3.8.0" - -preact@^10.6.3: - version "10.13.2" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.13.2.tgz#2c40c73d57248b57234c4ae6cd9ab9d8186ebc0a" - integrity sha512-q44QFLhOhty2Bd0Y46fnYW0gD/cbVM9dUVtNTDKPcdXSMA7jfY+Jpd6rk3GB0lcQss0z5s/6CmVP0Z/hV+g6pw== - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -5978,11 +5916,6 @@ pretty-format@^29.5.0: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385" - integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew== - promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -7409,11 +7342,6 @@ util-extend@^1.0.1: resolved "https://registry.yarnpkg.com/util-extend/-/util-extend-1.0.3.tgz#a7c216d267545169637b3b6edc6ca9119e2ff93f" integrity sha512-mLs5zAK+ctllYBj+iAQvlDCwoxU/WDOUaJkcFudeiAX6OajC6BKXJUa9a+tbtkC11dz2Ufb7h0lyvIOVn4LADA== -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" diff --git a/nginx/templates/development/default.conf.template b/nginx/templates/development/default.conf.template index c3c0a0d4..fcff280e 100644 --- a/nginx/templates/development/default.conf.template +++ b/nginx/templates/development/default.conf.template @@ -101,6 +101,11 @@ server { limit_req zone=one burst=10 nodelay; } + location /antiforgery { + proxy_pass http://backend; + limit_req zone=one burst=10 nodelay; + } + location /api { proxy_pass http://backend; limit_req zone=one burst=10 nodelay; diff --git a/nginx/templates/production/default.conf.template b/nginx/templates/production/default.conf.template index f0eb4186..ef13d2c0 100644 --- a/nginx/templates/production/default.conf.template +++ b/nginx/templates/production/default.conf.template @@ -84,6 +84,11 @@ server { limit_req zone=one burst=10 nodelay; } + location /antiforgery { + proxy_pass http://backend; + limit_req zone=one burst=10 nodelay; + } + location /api { proxy_pass http://backend; limit_req zone=one burst=10 nodelay; @@ -114,6 +119,7 @@ server { proxy_pass http://email; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; + proxy_cache_bypass $http_upgrade; limit_req zone=one burst=20 nodelay; }