192 lines
7.7 KiB
C#
192 lines
7.7 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using Distribution.Features.User;
|
|
using Distribution.Infra.Authorization;
|
|
using Microsoft.AspNetCore.Mvc.Filters;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace Distribution.Infra.Authorization.Middleware;
|
|
|
|
/// <summary>
|
|
/// Ported from OSMCore's System/Authentication/JwtMiddleware.cs (action-filter attribute pattern,
|
|
/// applied per controller/action as [JwtMiddleware.CustomMiddleware]). Not applied anywhere in
|
|
/// this project — the standard AddJwtBearer handler (Program.cs) is this project's actual
|
|
/// validation path.
|
|
///
|
|
/// docs/authentication_flow.md explicitly calls out not running this alongside AuthMiddleware or
|
|
/// AddJwtBearer at the same time — all three independently re-validate the same RSA-signed token,
|
|
/// which is wasteful and error-prone (this is the same duplication OSMCore's own guide flags as a
|
|
/// Gotcha). Kept here, unregistered, only as a ported reference implementation.
|
|
///
|
|
/// Same two adaptations as AuthMiddleware: portable RSA parsing via
|
|
/// <see cref="RsaXmlKeyReader"/>, and local User/Role → role_id/role_level claim mapping after
|
|
/// validation (see AuthMiddleware's remarks for why this is required, not optional).
|
|
/// </summary>
|
|
public sealed class JwtMiddleware
|
|
{
|
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
|
public sealed class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter
|
|
{
|
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
|
{
|
|
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
|
|
|
|
if (!TryGetToken(context.HttpContext, out var token))
|
|
{
|
|
await RejectAsync(context.HttpContext, "Authentication token is missing.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (configuration is null)
|
|
{
|
|
throw new InvalidOperationException("Jwt configuration is missing.");
|
|
}
|
|
|
|
var principal = ValidateToken(token, configuration);
|
|
|
|
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
|
|
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
|
|
{
|
|
await RejectAsync(context.HttpContext, "User not found or inactive.");
|
|
return;
|
|
}
|
|
|
|
context.HttpContext.User = principal;
|
|
PopulateContextItems(context.HttpContext, principal);
|
|
|
|
await next();
|
|
}
|
|
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException or InvalidOperationException)
|
|
{
|
|
await RejectAsync(context.HttpContext, "Invalid or expired token.");
|
|
}
|
|
}
|
|
|
|
private static ClaimsPrincipal ValidateToken(string token, IConfiguration configuration)
|
|
{
|
|
var xmlKey = configuration["Jwt:PublicKey"];
|
|
|
|
if (string.IsNullOrWhiteSpace(xmlKey))
|
|
throw new SecurityTokenException("JWT public key not configured.");
|
|
|
|
using var rsa = RsaXmlKeyReader.ReadPublicKey(xmlKey);
|
|
var rsaParameters = rsa.ExportParameters(false);
|
|
|
|
var key = new RsaSecurityKey(rsaParameters)
|
|
{
|
|
KeyId = ComputeKeyId(rsaParameters)
|
|
};
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = configuration["Jwt:Issuer"],
|
|
ValidateAudience = true,
|
|
ValidAudience = configuration["Jwt:Audience"],
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = key,
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(1)
|
|
};
|
|
|
|
return handler.ValidateToken(token, parameters, out _);
|
|
}
|
|
|
|
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
|
|
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
|
|
{
|
|
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (!Guid.TryParse(subClaim, out var userId))
|
|
return false;
|
|
|
|
var user = await userRepo.GetByIdAsync(userId);
|
|
if (user is null || !user.IsActive)
|
|
return false;
|
|
|
|
var identity = (ClaimsIdentity)principal.Identity!;
|
|
identity.AddClaim(new Claim("username", user.Username));
|
|
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
|
|
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetToken(HttpContext context, out string token)
|
|
{
|
|
token = string.Empty;
|
|
|
|
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
|
|
if (TryGetBearerToken(headerToken, out token))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var cookieToken = context.Request.Cookies["access_token"];
|
|
if (!string.IsNullOrWhiteSpace(cookieToken))
|
|
{
|
|
token = cookieToken.Trim();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
|
|
{
|
|
token = string.Empty;
|
|
|
|
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const string bearerPrefix = "Bearer ";
|
|
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
token = authorizationHeader.Trim();
|
|
return !string.IsNullOrWhiteSpace(token);
|
|
}
|
|
|
|
token = authorizationHeader[bearerPrefix.Length..].Trim();
|
|
return !string.IsNullOrWhiteSpace(token);
|
|
}
|
|
|
|
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
|
|
{
|
|
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
context.Items["Username"] = principal.FindFirst("username")?.Value;
|
|
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
|
|
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
|
|
}
|
|
|
|
private static async Task RejectAsync(HttpContext context, string message)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
|
|
{
|
|
StatusCode = StatusCodes.Status401Unauthorized,
|
|
Success = false,
|
|
Message = message,
|
|
Data = null
|
|
});
|
|
}
|
|
|
|
private static string ComputeKeyId(RSAParameters publicParameters)
|
|
{
|
|
var modulus = publicParameters.Modulus ?? [];
|
|
var exponent = publicParameters.Exponent ?? [];
|
|
var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}";
|
|
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
|
|
return Base64UrlEncoder.Encode(hash);
|
|
}
|
|
}
|
|
}
|