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.Http" Version="1.17.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
<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="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<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.Http" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ using KArtSell.Modules.ModelOperations;
|
|||||||
using KArtSell.Modules.ModelOperations.Scheduling;
|
using KArtSell.Modules.ModelOperations.Scheduling;
|
||||||
using KArtSell.Modules.SignalEngine;
|
using KArtSell.Modules.SignalEngine;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using OpenTelemetry.Metrics;
|
using OpenTelemetry.Metrics;
|
||||||
@@ -252,9 +253,25 @@ if (builder.Environment.IsDevelopment()
|
|||||||
}
|
}
|
||||||
else
|
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,
|
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();
|
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": {
|
"Authentication": {
|
||||||
"Mode": "DevelopmentHeader"
|
"Mode": "DevelopmentHeader"
|
||||||
},
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "KArtSell.Aegis.SecretKey.256Bits.v1.2026.Development.1234567890ABCDEF",
|
||||||
|
"Issuer": "KArtSell.Aegis",
|
||||||
|
"Audience": "KArtSell.Aegis",
|
||||||
|
"ExpirationMinutes": 60
|
||||||
|
},
|
||||||
"Capabilities": {
|
"Capabilities": {
|
||||||
"AutomaticOrder": false,
|
"AutomaticOrder": false,
|
||||||
"KisOrderAdapter": false,
|
"KisOrderAdapter": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user