gandalf-reborn/Security/Scheme/GandalfRebornJwtTokenAuthSchemeHandler.cs
2025-03-02 12:51:02 +01:00

157 lines
6.3 KiB
C#

using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
using Abstractions;
using HashidsNet;
using JWT.Algorithms;
using JWT.Builder;
using JWT.Serializers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using W542.GandalfReborn.Data.Database;
namespace Security.Scheme;
public class GandalfRebornJwtBody
{
public string? Id { get; init; }
public string? Sub { get; init; }
public DateTimeOffset? Iat { get; init; }
public DateTimeOffset? Exp { get; init; }
public string? Iss { get; init; }
public string? Aud { get; init; }
}
public partial class GandalfRebornJwtTokenAuthSchemeHandler(
IOptionsMonitor<GandalfRebornJwtTokenAuthSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IHashids hashIds,
ApplicationContext context,
IHttpContextAccessor httpContextAccessor,
InvokerContext invokerContext,
TimeProvider timeProvider)
: AuthenticationHandler<GandalfRebornJwtTokenAuthSchemeOptions>(options, logger, encoder)
{
[GeneratedRegex(@"Bearer\s+(?<token>[A-Za-z0-9\-._~+\/]+=*\.[A-Za-z0-9\-._~+\/]+=*\.[A-Za-z0-9\-._~+\/]+=*)")]
private static partial Regex BearerTokenRegex();
private string FailReason { get; set; } = "";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
var jwtBody = GetJwtTokenBody(httpContextAccessor.HttpContext);
if (!hashIds.TryDecodeSingleLong(jwtBody.Sub, out var subjectId))
{
throw new UnauthorizedAccessException("One does not simply authenticate with invalid id.");
}
var subjectExists = context.Subjects.Any(x => x.Id == subjectId);
if (!subjectExists)
{
throw new UnauthorizedAccessException("One does not simply authenticate with a not existing subject.");
}
var claims = new List<Claim>
{
new(Invoker.SubType, subjectId.ToString())
};
var tenantAuthorities = await context.TenantSubjectRelations
.AsNoTracking()
.Where(x => x.SubjectId == subjectId)
.Include(x => x.InternalAuthorities)
.ToDictionaryAsync(x => x.TenantId, x => x.InternalAuthorities.Select(authority => authority.Name));
var appAuthorities = await context.AppSubjectRelations
.AsNoTracking()
.Where(x => x.SubjectId == subjectId)
.Include(x => x.InternalAuthorities)
.ToDictionaryAsync(x => x.AppId, x => x.InternalAuthorities.Select(authority => authority.Name));
var tenantClaims = tenantAuthorities
.SelectMany(x => x.Value.Select(authority => new Claim($"{Invoker.TenantAuthorityPrefix}{Invoker.AuthoritySeparator}{hashIds.EncodeLong(x.Key)}", authority)));
var appClaims = appAuthorities
.SelectMany(x => x.Value.Select(authority => new Claim($"{Invoker.AppAuthorityPrefix}{Invoker.AuthoritySeparator}{hashIds.EncodeLong(x.Key)}", authority)));
claims.AddRange(tenantClaims);
claims.AddRange(appClaims);
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Tokens"));
invokerContext.Invoker = principal;
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
FailReason = ex.Message;
return AuthenticateResult.Fail(ex);
}
}
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
await base.HandleChallengeAsync(properties);
if (Response.StatusCode == StatusCodes.Status401Unauthorized &&
!string.IsNullOrWhiteSpace(FailReason))
{
Response.ContentType = "application/json";
var executor = Context.RequestServices.GetRequiredService<IActionResultExecutor<ObjectResult>>();
var actionContext = new ActionContext(Context, Context.GetRouteData(), new ActionDescriptor());
await executor.ExecuteAsync(actionContext, new UnauthorizedObjectResult(new
{
status = Response.StatusCode,
error = FailReason,
}));
}
}
private GandalfRebornJwtBody GetJwtTokenBody(HttpContext? httpContext)
{
var authHeader = httpContext?.Request.Headers.Authorization.ToString();
if (authHeader is null)
throw new UnauthorizedAccessException("One does not simply not provide an authorization header.");
var tokenRegexMatch = BearerTokenRegex().Match(authHeader);
if (!tokenRegexMatch.Success)
throw new UnauthorizedAccessException("One does not simply provide an invalid authorization header.");
var token = tokenRegexMatch.Groups["token"].Value;
var decodedToken = JwtBuilder.Create()
.WithAlgorithm(new HMACSHA512Algorithm())
.WithSecret(Options.JwtSecret)
.MustVerifySignature()
.WithJsonSerializer(new JsonNetSerializer())
.Decode<GandalfRebornJwtBody>(token);
if (decodedToken.Aud is null || !decodedToken.Aud.StartsWith(Options.BaseUrl))
throw new UnauthorizedAccessException("One does not simply provide a token for a different audience.");
if (decodedToken.Iss is null || !decodedToken.Iss.StartsWith(Options.BaseUrl))
throw new UnauthorizedAccessException("One does not simply provide a token from a unknown issuer.");
if (decodedToken.Exp is null || decodedToken.Exp?.ToUniversalTime() <= timeProvider.GetUtcNow())
throw new UnauthorizedAccessException("One does not simply provide an expired token.");
return decodedToken;
}
}