diff --git a/Foxchat.Core/Federation/RequestSigningService.Client.cs b/Foxchat.Core/Federation/RequestSigningService.Client.cs index eb84fb3..a3aa673 100644 --- a/Foxchat.Core/Federation/RequestSigningService.Client.cs +++ b/Foxchat.Core/Federation/RequestSigningService.Client.cs @@ -30,7 +30,7 @@ public partial class RequestSigningService if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(); - throw new FoxchatError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error)); + throw new FoxchatError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error)); } var bodyString = await resp.Content.ReadAsStringAsync(); diff --git a/Foxchat.Core/FoxchatError.cs b/Foxchat.Core/FoxchatError.cs index 61376cf..d395b7c 100644 --- a/Foxchat.Core/FoxchatError.cs +++ b/Foxchat.Core/FoxchatError.cs @@ -1,15 +1,31 @@ +using System.Net; + namespace Foxchat.Core; public class FoxchatError(string message) : Exception(message) { - public class ApiError(string message) : FoxchatError(message); - public class BadRequest(string message) : ApiError(message); - public class IncomingFederationError(string message) : FoxchatError(message); + public class BadRequest(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); + public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); - public class OutgoingFederationError(string message, Models.ApiError? innerError = null) : FoxchatError(message) + public class OutgoingFederationError( + string message, Models.ApiError? innerError = null + ) : ApiError(message, statusCode: HttpStatusCode.InternalServerError) { public Models.ApiError? InnerError => innerError; } public class DatabaseError(string message) : FoxchatError(message); + public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); +} + +public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxchatError(message) +{ + public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; + + public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized); + + public class Forbidden(string message, IEnumerable? scopes = null) : ApiError(message, statusCode: HttpStatusCode.Forbidden) + { + public readonly string[] Scopes = scopes?.ToArray() ?? []; + } } diff --git a/Foxchat.Identity/Authorization/AuthenticationHandler.cs b/Foxchat.Identity/Authorization/AuthenticationHandler.cs deleted file mode 100644 index 78223a2..0000000 --- a/Foxchat.Identity/Authorization/AuthenticationHandler.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Security.Cryptography; -using System.Text.Encodings.Web; -using Foxchat.Core; -using Foxchat.Core.Utils; -using Foxchat.Identity.Database; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.BearerToken; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using NodaTime; - -namespace Foxchat.Identity.Authorization; - -public static class AuthenticationHandlerExtensions -{ - public static void AddAuthenticationHandler(this IServiceCollection services) - { - - } -} - -public class FoxchatAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - IdentityContext context, - IClock clock -) : AuthenticationHandler(options, logger, encoder) -{ - protected override async Task HandleAuthenticateAsync() - { - var header = Request.Headers.Authorization.ToString(); - if (!header.StartsWith("bearer ", StringComparison.InvariantCultureIgnoreCase)) - return AuthenticateResult.NoResult(); - var token = header[7..]; - - if (!CryptoUtils.TryFromBase64String(token, out var rawToken)) - return AuthenticateResult.Fail(new FoxchatError.BadRequest("Invalid token format")); - - var hash = SHA512.HashData(rawToken); - var oauthToken = await context.Tokens - .Include(t => t.Account) - .Include(t => t.Application) - .FirstOrDefaultAsync(t => t.Hash == hash && t.Expires > clock.GetCurrentInstant()); - if (oauthToken == null) - return AuthenticateResult.NoResult(); - - throw new NotImplementedException(); - } -} diff --git a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs b/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs new file mode 100644 index 0000000..4b815d0 --- /dev/null +++ b/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs @@ -0,0 +1,77 @@ +using System.Security.Cryptography; +using Foxchat.Core; +using Foxchat.Core.Utils; +using Foxchat.Identity.Database; +using Foxchat.Identity.Database.Models; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace Foxchat.Identity.Authorization; + +public class AuthenticationMiddleware( + IdentityContext db, + IClock clock +) : IMiddleware +{ + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) + { + var endpoint = ctx.GetEndpoint(); + var metadata = endpoint?.Metadata.GetMetadata(); + + if (metadata == null) + { + await next(ctx); + return; + } + + var header = ctx.Request.Headers.Authorization.ToString(); + if (!header.StartsWith("bearer ", StringComparison.InvariantCultureIgnoreCase)) + { + await next(ctx); + return; + } + + var token = header[7..]; + + if (!CryptoUtils.TryFromBase64String(token, out var rawToken)) + { + await next(ctx); + return; + } + + var hash = SHA512.HashData(rawToken); + var oauthToken = await db.Tokens + .Include(t => t.Account) + .Include(t => t.Application) + .FirstOrDefaultAsync(t => t.Hash == hash && t.Expires > clock.GetCurrentInstant()); + if (oauthToken == null) + { + await next(ctx); + return; + } + + ctx.SetToken(oauthToken); + + await next(ctx); + } +} + +public static class HttpContextExtensions +{ + private const string Key = "token"; + + public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token); + public static Account? GetAccount(this HttpContext ctx) => ctx.GetToken()?.Account; + public static Account GetAccountOrThrow(this HttpContext ctx) => + ctx.GetAccount() ?? throw new FoxchatError.AuthenticationError("No account in HttpContext"); + + public static Token? GetToken(this HttpContext ctx) + { + if (ctx.Items.TryGetValue(Key, out var token)) + return token as Token; + return null; + } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class AuthenticateAttribute : Attribute; diff --git a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs b/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs new file mode 100644 index 0000000..2bb8203 --- /dev/null +++ b/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs @@ -0,0 +1,38 @@ +using System.Net; +using Foxchat.Core; +using Foxchat.Identity.Database; +using NodaTime; + +namespace Foxchat.Identity.Authorization; + +public class AuthorizationMiddleware( + IdentityContext db, + IClock clock +) : IMiddleware +{ + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) + { + var endpoint = ctx.GetEndpoint(); + var attribute = endpoint?.Metadata.GetMetadata(); + + if (attribute == null) + { + await next(ctx); + return; + } + + var token = ctx.GetToken(); + if (token == null || token.Expires > clock.GetCurrentInstant()) + throw new ApiError.Unauthorized("This endpoint requires an authenticated user."); + if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes).Any()) + throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes)); + + await next(ctx); + } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class AuthorizeAttribute(params string[] scopes) : Attribute +{ + public readonly string[] Scopes = scopes; +} diff --git a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..71546fd --- /dev/null +++ b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,13 @@ +using Foxchat.Identity.Authorization; + +namespace Foxchat.Identity.Extensions; + +public static class WebApplicationExtensions +{ + public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) + { + return app + .UseMiddleware() + .UseMiddleware(); + } +} diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index 79d1837..dfd8316 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -4,6 +4,7 @@ using Foxchat.Core; using Foxchat.Identity; using Foxchat.Identity.Database; using Foxchat.Identity.Services; +using Foxchat.Identity.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -31,6 +32,7 @@ builder.Services var app = builder.Build(); app.UseSerilogRequestLogging(); +app.UseCustomMiddleware(); app.UseRouting(); app.UseSwagger(); app.UseSwaggerUI();