diff --git a/docs/pages/configuration/index.md b/docs/pages/configuration/index.md index f5ded4e8..4454f682 100644 --- a/docs/pages/configuration/index.md +++ b/docs/pages/configuration/index.md @@ -105,7 +105,8 @@ Die folgenden Einstellungen können gesetzt werden, um die Benutzerauthentifizie |----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------| | `Identity__AccessTokenLifetime` | Die Gültigkeitsdauer von ausgestellten Access-Tokens. | `00:03:00` | | `Identity__RefreshTokenLifetime` | Die Gültigkeitsdauer von ausgestellten Refresh-Tokens. Innerhalb diesem Zeitraum ist kein erneuter Login erforderlich. | `1.00:00:00` | -| `Identity__StoragePath` | Das Verzeichnis innerhalb vom Container, wo der Schlüssel zur Signatur ausgesteller Tokens gespeichert wird. | `/var/turnierplan/identity` | +| `Identity__SigningKey` | Optional ein base64-kodierter 512-bit Schlüssel zur Signierung der ausgestellten Access- und Refresh-Tokens. | - | +| `Identity__StoragePath` | Falls kein Signature-Key angegeben wird, wird in diesem Verzeichnis ein zufällig generierter Schlüssel gespeichert. | `/var/turnierplan/identity` | | `Identity__UseInsecureCookies` | Kann auf `true` gesetzt werde, um HTTP Cookies ohne *secure* auszustellen. Dies ist erforderlich, wenn nicht mit HTTPS auf turnierplan.NET zugegriffen wird. | `false` | Für ein produktives Deployment sind die Standardwerte ausreichend und müssen nicht geändert werden. @@ -113,6 +114,9 @@ Für ein produktives Deployment sind die Standardwerte ausreichend und müssen n !!! note Die Gültigkeitsdauer muss als .NET `TimeSpan` formatiert werden. Das Format ist `HH:mm:ss` bzw. `d.HH:mm:ss` also bspw. `00:03:00` für 3 Minuten oder `1.00:00:00` für 1 Tag. +!!! note + Falls kein Signaturschlüssel festgelegt wird, sollte der `Identity__StoragePath` auf einen Pfad im Container verweisen, welcher als Volume persistiert wird. Ansonsten werden nach jedem Neustart des Containers alle zuvor ausgestellten Tokens ungültig, da ein neuer Schlüssel generiert werden würde. + ## Monitoring Der turnierplan.NET-Server kann Telemetriedaten (Logs, Metrics & Traces) an [Azure Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) senden: diff --git a/docs/pages/installation/index.md b/docs/pages/installation/index.md index 3d59d45d..8922ea5c 100644 --- a/docs/pages/installation/index.md +++ b/docs/pages/installation/index.md @@ -81,7 +81,7 @@ Beim ersten Starten der Anwendung werden alle Datenbankmigrationen durchgeführt Die turnierplan.NET-Anwendung speichert diverse Dateien in folgendem Verzeichnis innerhalb vom Container: `/var/turnierplan`. Bei einer Standardkonfiguration sollte dieses Verzeichnis in einem Docker Volume oder vergleichbar persistiert werden. Die folgenden Daten werden innerhalb vom o.g. Verzeichnis gespeichert: - **Bild-Uploads**: Sofern keine anderweitige Speicherung von Bildern (wie z.B. S3) konfiguriert ist. -- **JWT Signatur-Schlüssel**: Sofern kein Schlüssel via Umgebungsvariable spezifiziert wird, generiert die Anwendung beim ersten Start einen zufälligen symmetrischen SHA512 Schlüssel zur Signatur der JWT-Tokens. Wenn der Schlüssel nicht persistiert wird, wird er bei jedem Start des Servers neu generiert und alle zuvor ausgestellten JWT-Tokens werden ungültig. +- **JWT Signatur-Schlüssel**: Sofern kein Schlüssel via Umgebungsvariable spezifiziert wird, wird ein zufällig generierter Schlüssel hier gespeichert. Siehe auch [Konfiguration der Authentifizierung](../configuration/index.md#authentifizierung). ## Konfiguration diff --git a/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs index 0cafc230..0b329fc4 100644 --- a/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs @@ -84,7 +84,7 @@ private async Task Handle( // Give the user a new refresh token since the one he currently // holds is invalidated due to the updated security stamp. - var refreshToken = CreateTokenForUser(user, true); + var refreshToken = await CreateTokenForUserAsync(user, true, cancellationToken); AddResponseCookieForToken(context, refreshToken, true); return Results.Ok(new ChangePasswordEndpointResponse diff --git a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs index 9d468db6..9fef21f7 100644 --- a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs @@ -21,7 +21,7 @@ protected IdentityEndpointBase(IOptionsMonitor options, ISignin _signingKeyProvider = signingKeyProvider; } - protected string CreateTokenForUser(User user, bool isRefreshToken) + protected async Task CreateTokenForUserAsync(User user, bool isRefreshToken, CancellationToken cancellationToken) { var claims = new List(); @@ -62,11 +62,13 @@ protected string CreateTokenForUser(User user, bool isRefreshToken) var identityOptions = _options.CurrentValue; var tokenHandler = new JwtSecurityTokenHandler(); + + var signingKey = await _signingKeyProvider.GetSigningKeyAsync(cancellationToken); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims), Expires = DateTime.UtcNow + (isRefreshToken ? identityOptions.RefreshTokenLifetime : identityOptions.AccessTokenLifetime), - SigningCredentials = new SigningCredentials(_signingKeyProvider.GetSigningKey(), _signingKeyProvider.GetSigningAlgorithm()) + SigningCredentials = new SigningCredentials(signingKey, _signingKeyProvider.GetSigningAlgorithm()) }; return tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); diff --git a/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs index d0b686d7..2af942f7 100644 --- a/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs @@ -65,8 +65,8 @@ private async Task Handle( await userRepository.UnitOfWork.SaveChangesAsync(cancellationToken); - var accessToken = CreateTokenForUser(user, false); - var refreshToken = CreateTokenForUser(user, true); + var accessToken = await CreateTokenForUserAsync(user, false, cancellationToken); + var refreshToken = await CreateTokenForUserAsync(user, true, cancellationToken); AddResponseCookieForToken(context, accessToken, false); AddResponseCookieForToken(context, refreshToken, true); diff --git a/src/Turnierplan.App/Endpoints/Identity/RefreshEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/RefreshEndpoint.cs index 102ae3ab..ad2b2f1c 100644 --- a/src/Turnierplan.App/Endpoints/Identity/RefreshEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/RefreshEndpoint.cs @@ -24,7 +24,8 @@ public RefreshEndpoint(IOptionsMonitor options, ISigningKeyProv private async Task Handle( HttpContext context, - IUserRepository userRepository) + IUserRepository userRepository, + CancellationToken cancellationToken) { Guid userIdFromToken; Guid securityStampFromToken; @@ -35,10 +36,11 @@ private async Task Handle( var tokenHandler = new JwtSecurityTokenHandler(); + var signingKey = await _signingKeyProvider.GetSigningKeyAsync(cancellationToken); var validationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, - IssuerSigningKey = _signingKeyProvider.GetSigningKey(), + IssuerSigningKey = signingKey, ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, @@ -72,8 +74,8 @@ private async Task Handle( }); } - var accessToken = CreateTokenForUser(user, false); - var refreshToken = CreateTokenForUser(user, true); + var accessToken = await CreateTokenForUserAsync(user, false, cancellationToken); + var refreshToken = await CreateTokenForUserAsync(user, true, cancellationToken); AddResponseCookieForToken(context, accessToken, false); AddResponseCookieForToken(context, refreshToken, true); diff --git a/src/Turnierplan.App/Options/IdentityOptions.cs b/src/Turnierplan.App/Options/IdentityOptions.cs index 4520cf0a..2dcab951 100644 --- a/src/Turnierplan.App/Options/IdentityOptions.cs +++ b/src/Turnierplan.App/Options/IdentityOptions.cs @@ -4,7 +4,9 @@ namespace Turnierplan.App.Options; internal sealed class IdentityOptions : AuthenticationSchemeOptions { - public string? StoragePath { get; init; } = string.Empty; + public string? SigningKey { get; init; } + + public string? StoragePath { get; init; } public TimeSpan AccessTokenLifetime { get; init; } = TimeSpan.Zero; diff --git a/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs b/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs index f6e15f8c..695fbeb8 100644 --- a/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs +++ b/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs @@ -21,11 +21,11 @@ public JwtAuthenticationHandler( _signingKeyProvider = signingKeyProvider; } - protected override Task HandleAuthenticateAsync() + protected override async Task HandleAuthenticateAsync() { if (!Request.Cookies.ContainsKey(CookieNames.AccessTokenCookieName)) { - return Task.FromResult(AuthenticateResult.NoResult()); + return AuthenticateResult.NoResult(); } string token; @@ -36,22 +36,23 @@ protected override Task HandleAuthenticateAsync() } catch { - return Task.FromResult(AuthenticateResult.Fail("Missing or malformed access token cookie.")); + return AuthenticateResult.Fail("Missing or malformed access token cookie."); } if (string.IsNullOrEmpty(token)) { - return Task.FromResult(AuthenticateResult.Fail("Empty access token cookie.")); + return AuthenticateResult.Fail("Empty access token cookie."); } try { var tokenHandler = new JwtSecurityTokenHandler(); + var signingKey = await _signingKeyProvider.GetSigningKeyAsync(CancellationToken.None); var validationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, - IssuerSigningKey = _signingKeyProvider.GetSigningKey(), + IssuerSigningKey = signingKey, ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, @@ -64,16 +65,16 @@ protected override Task HandleAuthenticateAsync() if (!tokenType.Equals(JwtTokenTypes.Access)) { - return Task.FromResult(AuthenticateResult.Fail("Incorrect token type.")); + return AuthenticateResult.Fail("Incorrect token type."); } var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name); - return Task.FromResult(AuthenticateResult.Success(ticket)); + return AuthenticateResult.Success(ticket); } catch (Exception ex) { - return Task.FromResult(AuthenticateResult.Fail($"Invalid token: {ex.Message}")); + return AuthenticateResult.Fail($"Invalid token: {ex.Message}"); } } } diff --git a/src/Turnierplan.App/Security/SigningKeyProvider.cs b/src/Turnierplan.App/Security/SigningKeyProvider.cs index a5d3936c..ccfeb3e6 100644 --- a/src/Turnierplan.App/Security/SigningKeyProvider.cs +++ b/src/Turnierplan.App/Security/SigningKeyProvider.cs @@ -9,44 +9,153 @@ internal interface ISigningKeyProvider { string GetSigningAlgorithm(); - SymmetricSecurityKey GetSigningKey(); + Task GetSigningKeyAsync(CancellationToken cancellationToken); } internal sealed class SigningKeyProvider : ISigningKeyProvider { - private readonly SymmetricSecurityKey _signingKey; + private const int SigningKeySizeBytes = 512 / 8; + private const string SigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512"; + + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly IdentityOptions _options; + private readonly ILogger _logger; + + private bool _initializationAttempted; + private SymmetricSecurityKey? _signingKey; public SigningKeyProvider(IOptions options, ILogger logger) { - ArgumentException.ThrowIfNullOrWhiteSpace(options.Value.StoragePath); + _logger = logger; + _options = options.Value; + } + + public string GetSigningAlgorithm() + { + return SigningAlgorithm; + } + + public async Task GetSigningKeyAsync(CancellationToken cancellationToken) + { + if (_signingKey is not null) + { + return _signingKey; + } + + try + { + await _semaphore.WaitAsync(cancellationToken); + + // Signing key could have been set while waiting for the semaphore + if (_signingKey is not null) + { + return _signingKey; + } + + // Only attempt to initialize the sining key once + if (!_initializationAttempted) + { + await InitializeSigningKey(cancellationToken); + + _initializationAttempted = true; + } + + // If signing key is still null, the initialization failed + return _signingKey ?? throw new InvalidOperationException("The signing key is not available: Check the startup logs for error details."); + } + finally + { + _semaphore.Release(); + } + } + + private async Task InitializeSigningKey(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_options.SigningKey)) + { + _logger.LogInformation("Initializing signing key from app config."); + InitializeSigningKeyFromAppConfig(); + } + else if (!string.IsNullOrWhiteSpace(_options.StoragePath)) + { + _logger.LogInformation("Initializing signing key from file store."); + await InitializeSigningKeyWithFileStore(cancellationToken); + } + else + { + _logger.LogCritical("Either signing key or storage path must be specified."); + } + } + + private void InitializeSigningKeyFromAppConfig() + { + ArgumentException.ThrowIfNullOrWhiteSpace(_options.SigningKey); + + byte[] signingKey; + + try + { + signingKey = Convert.FromBase64String(_options.SigningKey); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Signing key from app configuration could not be decoded."); + return; + } + + if (signingKey.Length == SigningKeySizeBytes) + { + _signingKey = new SymmetricSecurityKey(signingKey); + } + else + { + _logger.LogCritical("Signing key from app configuration must be {ExpectedSize} bytes long, but it is {ActualSize}.", SigningKeySizeBytes, signingKey.Length); + } + } + + private async Task InitializeSigningKeyWithFileStore(CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(_options.StoragePath); - var storagePath = Path.GetFullPath(options.Value.StoragePath); + var storagePath = Path.GetFullPath(_options.StoragePath); Directory.CreateDirectory(storagePath); if (!Directory.Exists(storagePath)) { - logger.LogCritical("The directory for identity storage does not exist and could not be created."); + _logger.LogCritical("The directory for identity storage does not exist and could not be created."); + return; } var signingKeyFile = Path.Join(storagePath, "jwt-signing-key.bin"); - byte[] signingKey; if (File.Exists(signingKeyFile)) { - signingKey = File.ReadAllBytes(signingKeyFile); + try + { + signingKey = await File.ReadAllBytesAsync(signingKeyFile, cancellationToken); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Signing key could not be loaded from file: {SigningKeyFilePath}", signingKeyFile); + return; + } } else { - signingKey = RandomNumberGenerator.GetBytes(512 / 8); - File.WriteAllBytes(signingKeyFile, signingKey); + signingKey = RandomNumberGenerator.GetBytes(SigningKeySizeBytes); + + try + { + await File.WriteAllBytesAsync(signingKeyFile, signingKey, cancellationToken); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Signing key could not be written to file: {SigningKeyFilePath}", signingKeyFile); + } } _signingKey = new SymmetricSecurityKey(signingKey); } - - public string GetSigningAlgorithm() => "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512"; - - public SymmetricSecurityKey GetSigningKey() => _signingKey; } diff --git a/src/Turnierplan.App/appsettings.json b/src/Turnierplan.App/appsettings.json index 78852a72..8cd26a2b 100644 --- a/src/Turnierplan.App/appsettings.json +++ b/src/Turnierplan.App/appsettings.json @@ -6,6 +6,7 @@ "Microsoft.EntityFrameworkCore.Migrations": "Information", "Microsoft.Hosting.Lifetime": "Information", "Turnierplan.App.DatabaseMigrator": "Information", + "Turnierplan.App.Security": "Information", "Turnierplan.ImageStorage": "Information" } },