136 lines
4.4 KiB
C#
136 lines
4.4 KiB
C#
|
|
using StellaOps.Router.Common.Models;
|
|
using StellaOps.Router.Gateway;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Gateway.WebService.Authorization;
|
|
|
|
public sealed class AuthorizationMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly IEffectiveClaimsStore _claimsStore;
|
|
private readonly ILogger<AuthorizationMiddleware> _logger;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false
|
|
};
|
|
|
|
public AuthorizationMiddleware(
|
|
RequestDelegate next,
|
|
IEffectiveClaimsStore claimsStore,
|
|
ILogger<AuthorizationMiddleware> logger)
|
|
{
|
|
_next = next;
|
|
_claimsStore = claimsStore;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
if (!context.Items.TryGetValue(RouterHttpContextKeys.EndpointDescriptor, out var endpointObj) ||
|
|
endpointObj is not EndpointDescriptor endpoint)
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
if (endpoint.AllowAnonymous)
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
var requiresAuthentication = EndpointAuthorizationSemantics.ResolveRequiresAuthentication(endpoint);
|
|
var isAuthenticated = context.User.Identity?.IsAuthenticated == true;
|
|
if (requiresAuthentication && !isAuthenticated)
|
|
{
|
|
_logger.LogWarning(
|
|
"Authorization failed for {Method} {Path}: unauthenticated principal",
|
|
endpoint.Method,
|
|
endpoint.Path);
|
|
|
|
await WriteUnauthorizedAsync(context, endpoint);
|
|
return;
|
|
}
|
|
|
|
var effectiveClaims = _claimsStore.GetEffectiveClaims(
|
|
endpoint.ServiceName,
|
|
endpoint.Method,
|
|
endpoint.Path);
|
|
|
|
if (effectiveClaims.Count == 0)
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
foreach (var required in effectiveClaims)
|
|
{
|
|
var userClaims = context.User.Claims;
|
|
var hasClaim = required.Value == null
|
|
? userClaims.Any(c => c.Type == required.Type)
|
|
: userClaims.Any(c => c.Type == required.Type && c.Value == required.Value);
|
|
|
|
if (!hasClaim)
|
|
{
|
|
_logger.LogWarning(
|
|
"Authorization failed for {Method} {Path}: user lacks claim {ClaimType}={ClaimValue}",
|
|
endpoint.Method,
|
|
endpoint.Path,
|
|
required.Type,
|
|
required.Value ?? "(any)");
|
|
|
|
await WriteForbiddenAsync(context, endpoint, required);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await _next(context);
|
|
}
|
|
|
|
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
context.Response.ContentType = "application/json; charset=utf-8";
|
|
|
|
var payload = new AuthorizationFailureResponse(
|
|
Error: "unauthorized",
|
|
Message: "Authentication required",
|
|
RequiredClaimType: string.Empty,
|
|
RequiredClaimValue: null,
|
|
Service: endpoint.ServiceName,
|
|
Version: endpoint.Version);
|
|
|
|
return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions, context.RequestAborted);
|
|
}
|
|
|
|
private static Task WriteForbiddenAsync(
|
|
HttpContext context,
|
|
EndpointDescriptor endpoint,
|
|
ClaimRequirement required)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
context.Response.ContentType = "application/json; charset=utf-8";
|
|
|
|
var payload = new AuthorizationFailureResponse(
|
|
Error: "forbidden",
|
|
Message: "Authorization failed: missing required claim",
|
|
RequiredClaimType: required.Type,
|
|
RequiredClaimValue: required.Value,
|
|
Service: endpoint.ServiceName,
|
|
Version: endpoint.Version);
|
|
|
|
return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions, context.RequestAborted);
|
|
}
|
|
|
|
private sealed record AuthorizationFailureResponse(
|
|
string Error,
|
|
string Message,
|
|
string RequiredClaimType,
|
|
string? RequiredClaimValue,
|
|
string Service,
|
|
string Version);
|
|
}
|