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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private async Task<IResult> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ protected IdentityEndpointBase(IOptionsMonitor<IdentityOptions> options, ISignin
_signingKeyProvider = signingKeyProvider;
}

protected string CreateTokenForUser(User user, bool isRefreshToken)
protected async Task<string> CreateTokenForUserAsync(User user, bool isRefreshToken, CancellationToken cancellationToken)
{
var claims = new List<Claim>();

Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ private async Task<IResult> 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);
Expand Down
10 changes: 6 additions & 4 deletions src/Turnierplan.App/Endpoints/Identity/RefreshEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public RefreshEndpoint(IOptionsMonitor<IdentityOptions> options, ISigningKeyProv

private async Task<IResult> Handle(
HttpContext context,
IUserRepository userRepository)
IUserRepository userRepository,
CancellationToken cancellationToken)
{
Guid userIdFromToken;
Guid securityStampFromToken;
Expand All @@ -35,10 +36,11 @@ private async Task<IResult> 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,
Expand Down Expand Up @@ -72,8 +74,8 @@ private async Task<IResult> 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);
Expand Down
4 changes: 3 additions & 1 deletion src/Turnierplan.App/Options/IdentityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
17 changes: 9 additions & 8 deletions src/Turnierplan.App/Security/JwtAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ public JwtAuthenticationHandler(
_signingKeyProvider = signingKeyProvider;
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Cookies.ContainsKey(CookieNames.AccessTokenCookieName))
{
return Task.FromResult(AuthenticateResult.NoResult());
return AuthenticateResult.NoResult();
}

string token;
Expand All @@ -36,22 +36,23 @@ protected override Task<AuthenticateResult> 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,
Expand All @@ -64,16 +65,16 @@ protected override Task<AuthenticateResult> 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}");
}
}
}
133 changes: 120 additions & 13 deletions src/Turnierplan.App/Security/SigningKeyProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,151 @@
{
string GetSigningAlgorithm();

SymmetricSecurityKey GetSigningKey();
Task<SymmetricSecurityKey> 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<SigningKeyProvider> _logger;

private bool _initializationAttempted;
private SymmetricSecurityKey? _signingKey;

public SigningKeyProvider(IOptions<IdentityOptions> options, ILogger<SigningKeyProvider> logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(options.Value.StoragePath);
_logger = logger;
_options = options.Value;
}

public string GetSigningAlgorithm()
{
return SigningAlgorithm;
}

public async Task<SymmetricSecurityKey> 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);

Check warning on line 110 in src/Turnierplan.App/Security/SigningKeyProvider.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzJvfhw1fjrWnJezQSn&open=AZzJvfhw1fjrWnJezQSn&pullRequest=362
}
}

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);

Check warning on line 139 in src/Turnierplan.App/Security/SigningKeyProvider.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzJvfhw1fjrWnJezQSo&open=AZzJvfhw1fjrWnJezQSo&pullRequest=362
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);

Check warning on line 153 in src/Turnierplan.App/Security/SigningKeyProvider.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzJvfhw1fjrWnJezQSp&open=AZzJvfhw1fjrWnJezQSp&pullRequest=362
}
}

_signingKey = new SymmetricSecurityKey(signingKey);
}

public string GetSigningAlgorithm() => "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512";

public SymmetricSecurityKey GetSigningKey() => _signingKey;
}
Loading