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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/pages/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,18 @@ 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.

!!! 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:
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/installation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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 @@ -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));
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}");
}
}
}
135 changes: 122 additions & 13 deletions src/Turnierplan.App/Security/SigningKeyProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,153 @@
{
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, 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("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);

Check warning on line 112 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 141 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 155 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;
}
1 change: 1 addition & 0 deletions src/Turnierplan.App/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"Microsoft.EntityFrameworkCore.Migrations": "Information",
"Microsoft.Hosting.Lifetime": "Information",
"Turnierplan.App.DatabaseMigrator": "Information",
"Turnierplan.App.Security": "Information",
"Turnierplan.ImageStorage": "Information"
}
},
Expand Down
Loading