fix the ride create issue
This commit is contained in:
@@ -1,185 +0,0 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Distribution.Features.User;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Distribution.Infra.Authorization.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Ported from OSMCore's System/Authentication/AuthMiddleware.cs (global path-skip-based
|
||||
/// middleware). NOT registered in Program.cs — this project's actual token validation is the
|
||||
/// standard AddJwtBearer handler (see Program.cs), same as OSMCore's own recommendation. Kept here
|
||||
/// unregistered, exactly like OSMCore's own copy, for reference/reuse if a project needs a
|
||||
/// middleware-based (rather than AddJwtBearer-based) validation entry point.
|
||||
///
|
||||
/// Two adaptations from the OSMCore original, both required for this to actually work against
|
||||
/// this project's authorization system rather than just compiling:
|
||||
/// 1. RSA key parsing goes through <see cref="RsaXmlKeyReader"/> instead of RSA.FromXmlString,
|
||||
/// which is Windows-only in modern .NET (see docs/authentication_flow.md §2.2 Gotcha).
|
||||
/// 2. After validating the token, the user id is mapped onto a local User/Role row and
|
||||
/// role_id/role_level/username claims are attached — the same step Program.cs's
|
||||
/// OnTokenValidated performs for the AddJwtBearer path. Without this, a request authenticated
|
||||
/// through this middleware would have no role_id claim, and every
|
||||
/// [Authorize(Policy = "resource.action")] check (PermissionAuthorizationHandler) would fail.
|
||||
/// </summary>
|
||||
public sealed class AuthMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<AuthMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (ShouldSkipAuthentication(context.Request.Path))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetToken(context, out var token))
|
||||
{
|
||||
await RejectAsync(context, "Authentication token is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var principal = ValidateToken(token);
|
||||
var userRepo = context.RequestServices.GetRequiredService<IUserRepository>();
|
||||
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
|
||||
{
|
||||
await RejectAsync(context, "User not found or inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
context.User = principal;
|
||||
PopulateContextItems(context, principal);
|
||||
await next(context);
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException)
|
||||
{
|
||||
logger.LogInformation(ex, "JWT validation failed for request path {Path}", context.Request.Path);
|
||||
await RejectAsync(context, "Invalid or expired token.");
|
||||
}
|
||||
}
|
||||
|
||||
private ClaimsPrincipal ValidateToken(string token)
|
||||
{
|
||||
var jwtOptions = configuration.GetSection("Jwt").Get<JwtOptions>()
|
||||
?? throw new InvalidOperationException("Jwt configuration is missing.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jwtOptions.PublicKey))
|
||||
{
|
||||
throw new InvalidOperationException("Jwt:PublicKey configuration is missing.");
|
||||
}
|
||||
|
||||
using var rsa = RsaXmlKeyReader.ReadPublicKey(jwtOptions.PublicKey);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new RsaSecurityKey(rsa),
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapted from OSMCore's path list: this project has no Shopify-style webhook route, so that
|
||||
/// skip is dropped; /api/auth is narrowed to just login/register (this project's /api/auth/me
|
||||
/// and /api/auth/logout are meant to require auth, unlike a blanket "/api/auth" skip).
|
||||
/// </summary>
|
||||
private static bool ShouldSkipAuthentication(PathString path)
|
||||
=> path.Equals("/health", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Equals("/api/auth/register", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user