diff --git a/Directory.Packages.props b/Directory.Packages.props index aa10fbb0..3950c297 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs b/src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs new file mode 100644 index 00000000..64957db6 --- /dev/null +++ b/src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs @@ -0,0 +1,101 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using FastEndpoints; +using Microsoft.IdentityModel.Tokens; + +namespace KArtSell.Host.Endpoints.Auth; + +public sealed class LoginEndpoint(IConfiguration config, ILogger logger) + : Endpoint +{ + public override void Configure() + { + Post("/login"); + AllowAnonymous(); + } + + public override async Task HandleAsync(LoginRequest req, CancellationToken ct) + { + logger.LogInformation("Login attempt for user: {User}", req.Username); + + if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) + { + logger.LogWarning("Login failed: missing credentials"); + await SendErrorAsync(401, "Unauthorized", ct); + return; + } + + var token = GenerateJwtToken(req.Username, req.Role ?? "User"); + logger.LogInformation("Token issued for user: {User}", req.Username); + + var expirationMinutes = config.GetValue("Jwt:ExpirationMinutes"); + if (expirationMinutes == 0) + { + expirationMinutes = 60; + } + + var response = new LoginResponse + { + AccessToken = token, + ExpiresIn = expirationMinutes * 60, + TokenType = "Bearer" + }; + + await Send.OkAsync(response, ct); + } + + private async Task SendErrorAsync(int statusCode, string message, CancellationToken ct) + { + await Send.StatusCodeAsync(statusCode, ct); + } + + private string GenerateJwtToken(string username, string role) + { + var jwtKey = config.GetValue("Jwt:Key") + ?? throw new InvalidOperationException("JWT key not configured"); + var jwtIssuer = config.GetValue("Jwt:Issuer") + ?? throw new InvalidOperationException("JWT issuer not configured"); + var jwtAudience = config.GetValue("Jwt:Audience") + ?? throw new InvalidOperationException("JWT audience not configured"); + var expirationMinutes = config.GetValue("Jwt:ExpirationMinutes"); + if (expirationMinutes == 0) + { + expirationMinutes = 60; + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, username), + new Claim(ClaimTypes.Name, username), + new Claim(ClaimTypes.Role, role), + new Claim("auth_mode", "jwt") + }; + + var token = new JwtSecurityToken( + issuer: jwtIssuer, + audience: jwtAudience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(expirationMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + +public class LoginRequest +{ + public required string Username { get; set; } + public required string Password { get; set; } + public string? Role { get; set; } +} + +public class LoginResponse +{ + public required string AccessToken { get; set; } + public required int ExpiresIn { get; set; } + public required string TokenType { get; set; } +} diff --git a/src/KArtSell.Host/KArtSell.Host.csproj b/src/KArtSell.Host/KArtSell.Host.csproj index e0e431c8..f40a2e72 100644 --- a/src/KArtSell.Host/KArtSell.Host.csproj +++ b/src/KArtSell.Host/KArtSell.Host.csproj @@ -36,5 +36,6 @@ + diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs index fba8cf2d..d5a296ad 100644 --- a/src/KArtSell.Host/Program.cs +++ b/src/KArtSell.Host/Program.cs @@ -18,6 +18,7 @@ using KArtSell.Modules.ModelOperations; using KArtSell.Modules.ModelOperations.Scheduling; using KArtSell.Modules.SignalEngine; using Microsoft.AspNetCore.Authentication; +using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using Npgsql; using OpenTelemetry.Metrics; @@ -252,9 +253,25 @@ if (builder.Environment.IsDevelopment() } else { - authenticationBuilder.AddScheme( + // Release mode: JWT Token-based authentication + var jwtKey = builder.Configuration["Jwt:Key"] + ?? throw new InvalidOperationException("Jwt:Key is required in production"); + var jwtIssuer = builder.Configuration["Jwt:Issuer"] + ?? throw new InvalidOperationException("Jwt:Issuer is required in production"); + var jwtAudience = builder.Configuration["Jwt:Audience"] + ?? throw new InvalidOperationException("Jwt:Audience is required in production"); + + authenticationBuilder.AddScheme( authenticationScheme, - _ => { }); + options => + { + options.JwtKey = jwtKey; + options.JwtIssuer = jwtIssuer; + options.JwtAudience = jwtAudience; + var expirationMinutes = builder.Configuration.GetValue("Jwt:ExpirationMinutes"); + options.ExpirationMinutes = expirationMinutes == 0 ? 60 : expirationMinutes; + options.ClockSkewSeconds = 30; + }); } builder.Services.AddAuthorization(); diff --git a/src/KArtSell.Host/Security/JwtAuthenticationHandler.cs b/src/KArtSell.Host/Security/JwtAuthenticationHandler.cs new file mode 100644 index 00000000..1d9e06bf --- /dev/null +++ b/src/KArtSell.Host/Security/JwtAuthenticationHandler.cs @@ -0,0 +1,73 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace KArtSell.Host.Security; + +/// +/// JWT Token-based authentication for production. +/// Validates Bearer tokens from Authorization header. +/// +public sealed class JwtAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + private static readonly JwtSecurityTokenHandler TokenHandler = new(); + + protected override Task HandleAuthenticateAsync() + { + try + { + // Extract Bearer token from Authorization header + var authHeader = Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.Ordinal)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var token = authHeader["Bearer ".Length..]; + + // Validate token + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(Options.JwtKey)), + ValidateIssuer = true, + ValidIssuer = Options.JwtIssuer, + ValidateAudience = true, + ValidAudience = Options.JwtAudience, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(Options.ClockSkewSeconds) + }; + + var principal = TokenHandler.ValidateToken(token, validationParameters, out _); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + catch (Exception ex) + { + Logger.LogWarning("JWT validation failed: {Message}", ex.Message); + return Task.FromResult(AuthenticateResult.Fail("Invalid token")); + } + } +} + +/// +/// Configuration options for JWT authentication. +/// +public class JwtAuthenticationOptions : AuthenticationSchemeOptions +{ + public string JwtKey { get; set; } = string.Empty; + public string JwtIssuer { get; set; } = string.Empty; + public string JwtAudience { get; set; } = string.Empty; + public int ExpirationMinutes { get; set; } = 60; + public int ClockSkewSeconds { get; set; } = 30; +} diff --git a/src/KArtSell.Host/appsettings.json b/src/KArtSell.Host/appsettings.json index 8a961f07..ae59cd6a 100644 --- a/src/KArtSell.Host/appsettings.json +++ b/src/KArtSell.Host/appsettings.json @@ -27,6 +27,12 @@ "Authentication": { "Mode": "DevelopmentHeader" }, + "Jwt": { + "Key": "KArtSell.Aegis.SecretKey.256Bits.v1.2026.Development.1234567890ABCDEF", + "Issuer": "KArtSell.Aegis", + "Audience": "KArtSell.Aegis", + "ExpirationMinutes": 60 + }, "Capabilities": { "AutomaticOrder": false, "KisOrderAdapter": false,