feat: JWT Token-based authentication for production (Release mode)
- Implemented JwtAuthenticationHandler for Bearer token validation - Created LoginEndpoint for JWT token issuance (POST /api/auth/login) - Added JWT configuration to appsettings.json (Key, Issuer, Audience, ExpirationMinutes) - Updated Program.cs to use JWT authentication in Release mode (replaces FailClosedAuthenticationHandler) - Registered System.IdentityModel.Tokens.Jwt NuGet package - Token validation includes issuer, audience, expiration, and configurable clock skew - Backward compatible: Development mode continues to use DevelopmentHeaderAuthenticationHandler This enables production deployments to use standard JWT-based authentication instead of rejecting all requests. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="7.3.0" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
|
||||
@@ -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<LoginEndpoint> logger)
|
||||
: Endpoint<LoginRequest, LoginResponse>
|
||||
{
|
||||
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<int>("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<string>("Jwt:Key")
|
||||
?? throw new InvalidOperationException("JWT key not configured");
|
||||
var jwtIssuer = config.GetValue<string>("Jwt:Issuer")
|
||||
?? throw new InvalidOperationException("JWT issuer not configured");
|
||||
var jwtAudience = config.GetValue<string>("Jwt:Audience")
|
||||
?? throw new InvalidOperationException("JWT audience not configured");
|
||||
var expirationMinutes = config.GetValue<int>("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; }
|
||||
}
|
||||
@@ -36,5 +36,6 @@
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<AuthenticationSchemeOptions, FailClosedAuthenticationHandler>(
|
||||
// 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<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||
authenticationScheme,
|
||||
_ => { });
|
||||
options =>
|
||||
{
|
||||
options.JwtKey = jwtKey;
|
||||
options.JwtIssuer = jwtIssuer;
|
||||
options.JwtAudience = jwtAudience;
|
||||
var expirationMinutes = builder.Configuration.GetValue<int>("Jwt:ExpirationMinutes");
|
||||
options.ExpirationMinutes = expirationMinutes == 0 ? 60 : expirationMinutes;
|
||||
options.ClockSkewSeconds = 30;
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// JWT Token-based authentication for production.
|
||||
/// Validates Bearer tokens from Authorization header.
|
||||
/// </summary>
|
||||
public sealed class JwtAuthenticationHandler(
|
||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||
{
|
||||
private static readonly JwtSecurityTokenHandler TokenHandler = new();
|
||||
|
||||
protected override Task<AuthenticateResult> 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for JWT authentication.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user