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 8ce54528..c5455a20 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(); @@ -57,11 +57,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..28e0e304 100644 --- a/src/Turnierplan.App/Security/SigningKeyProvider.cs +++ b/src/Turnierplan.App/Security/SigningKeyProvider.cs @@ -9,44 +9,151 @@ 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); + 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("Signing key initialization failed. Check the logs for more details"); + } + finally + { + _semaphore.Release(); + } + } + + private async Task InitializeSigningKey(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_options.SigningKey)) + { + InitializeSigningKeyFromAppConfig(); + } + else if (!string.IsNullOrWhiteSpace(_options.StoragePath)) + { + 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; }