From cec4abd841e9652cb51bcbfab2c940785df40d3e Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Mon, 29 Jan 2024 03:25:39 +0100 Subject: [PATCH] Mastodon client API foundations --- .../Mastodon/MastodonAuthController.cs | 59 ++++++++++++++- .../Mastodon/Schemas/MastodonAuth.cs | 61 +++++++++++++++ .../Mastodon/Schemas/MastodonErrorResponse.cs | 8 ++ .../ModelBinderProviderExtensions.cs | 67 +++++++++++++++++ .../Core/Extensions/ServiceExtensions.cs | 19 ++++- .../Core/Helpers/JsonConverters.cs | 34 +++++++++ .../Core/Helpers/MastodonOauthHelpers.cs | 74 +++++++++++++++++++ .../Middleware/AuthenticationMiddleware.cs | 4 +- .../Core/Middleware/ErrorHandlerMiddleware.cs | 8 ++ .../OauthAuthenticationMiddleware.cs | 66 +++++++++++++++++ .../OauthAuthorizationMiddleware.cs | 18 +++++ Iceshrimp.Backend/Iceshrimp.Backend.csproj | 44 +++++------ Iceshrimp.Backend/Startup.cs | 4 +- 13 files changed, 434 insertions(+), 32 deletions(-) create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonAuth.cs create mode 100644 Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonErrorResponse.cs create mode 100644 Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs create mode 100644 Iceshrimp.Backend/Core/Helpers/JsonConverters.cs create mode 100644 Iceshrimp.Backend/Core/Helpers/MastodonOauthHelpers.cs create mode 100644 Iceshrimp.Backend/Core/Middleware/OauthAuthenticationMiddleware.cs create mode 100644 Iceshrimp.Backend/Core/Middleware/OauthAuthorizationMiddleware.cs diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs index 3d08ae99..f31e0e3d 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MastodonAuthController.cs @@ -1,3 +1,9 @@ +using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Helpers; +using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -8,4 +14,55 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableRateLimiting("sliding")] [Produces("application/json")] [Route("/api/v1")] -public class MastodonAuthController : Controller { } \ No newline at end of file +public class MastodonAuthController(DatabaseContext db) : Controller { + [AuthenticateOauth] + [HttpGet("verify_credentials")] + [Produces("application/json")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MastodonAuth.VerifyCredentialsResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] + public IActionResult VerifyCredentials() { + var token = HttpContext.GetOauthToken(); + if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); + + var res = new MastodonAuth.VerifyCredentialsResponse { + App = token.App, + VapidKey = null //FIXME + }; + + return Ok(res); + } + + [HttpPost("apps")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + [Produces("application/json")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MastodonAuth.RegisterAppResponse))] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] + public async Task RegisterApp([FromHybrid] MastodonAuth.RegisterAppRequest request) { + if (request.RedirectUris.Count == 0) + throw GracefulException.BadRequest("Invalid redirect_uris parameter"); + + if (request.RedirectUris.Any(p => !MastodonOauthHelpers.ValidateRedirectUri(p))) + throw GracefulException.BadRequest("redirect_uris parameter contains invalid protocols"); + + var app = new OauthApp { + Id = IdHelpers.GenerateSlowflakeId(), + ClientId = CryptographyHelpers.GenerateRandomString(32), + ClientSecret = CryptographyHelpers.GenerateRandomString(32), + CreatedAt = DateTime.UtcNow, + Name = request.ClientName, + Website = request.Website, + Scopes = request.Scopes, + RedirectUris = request.RedirectUris, + }; + + await db.AddAsync(app); + await db.SaveChangesAsync(); + + var res = new MastodonAuth.RegisterAppResponse { + App = app, + VapidKey = null //FIXME + }; + + return Ok(res); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonAuth.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonAuth.cs new file mode 100644 index 00000000..3e5a8e85 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonAuth.cs @@ -0,0 +1,61 @@ +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Helpers; +using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; +using JR = System.Text.Json.Serialization.JsonRequiredAttribute; +using JC = System.Text.Json.Serialization.JsonConverterAttribute; +using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; + +public abstract class MastodonAuth { + public class VerifyCredentialsResponse { + public required OauthApp App; + + [J("name")] public string Name => App.Name; + [J("website")] public string? Website => App.Website; + + [J("vapid_key")] public required string? VapidKey { get; set; } + } + + public class RegisterAppRequest { + private List _scopes = ["read"]; + public List RedirectUris = []; + + [B(Name = "scopes")] + [J("scopes")] + [JC(typeof(EnsureArrayConverter))] + public List Scopes { + get => _scopes; + set => _scopes = value.Count == 1 + ? value[0].Split(' ').ToList() + : value; + } + + [B(Name = "client_name")] + [J("client_name")] + [JR] + public string ClientName { get; set; } = null!; + + [B(Name = "website")] [J("website")] public string? Website { get; set; } + + [B(Name = "redirect_uris")] + [J("redirect_uris")] + public string RedirectUrisInternal { + set => RedirectUris = value.Split('\n').ToList(); + get => string.Join('\n', RedirectUris); + } + } + + public class RegisterAppResponse { + public required OauthApp App; + + [J("id")] public string Id => App.Id; + [J("name")] public string Name => App.Name; + [J("website")] public string? Website => App.Website; + [J("client_id")] public string ClientId => App.ClientId; + [J("client_secret")] public string ClientSecret => App.ClientSecret; + [J("redirect_uri")] public string RedirectUri => string.Join("\n", App.RedirectUris); + + [J("vapid_key")] public required string? VapidKey { get; set; } + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonErrorResponse.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonErrorResponse.cs new file mode 100644 index 00000000..5f649988 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/MastodonErrorResponse.cs @@ -0,0 +1,8 @@ +using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; + +namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; + +public class MastodonErrorResponse { + [J("error")] public required string Error { get; set; } + [J("error_description")] public required string? Description { get; set; } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs new file mode 100644 index 00000000..079912f0 --- /dev/null +++ b/Iceshrimp.Backend/Core/Extensions/ModelBinderProviderExtensions.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +namespace Iceshrimp.Backend.Core.Extensions; + +public static class ModelBinderProviderExtensions { + public static void AddHybridBindingProvider(this IList providers) { + if (providers.Single(provider => provider.GetType() == typeof(BodyModelBinderProvider)) is not + BodyModelBinderProvider bodyProvider || + providers.Single(provider => provider.GetType() == typeof(ComplexObjectModelBinderProvider)) is not + ComplexObjectModelBinderProvider complexProvider) + throw new Exception("Failed to set up hybrid model binding provider"); + + var hybridProvider = new HybridModelBinderProvider(bodyProvider, complexProvider); + + providers.Insert(0, hybridProvider); + } +} + +public class HybridModelBinderProvider( + IModelBinderProvider bodyProvider, + IModelBinderProvider complexProvider) : IModelBinderProvider { + public IModelBinder? GetBinder(ModelBinderProviderContext context) { + if (context.BindingInfo.BindingSource == null) return null; + if (!context.BindingInfo.BindingSource.CanAcceptDataFrom(HybridBindingSource.Hybrid)) return null; + + context.BindingInfo.BindingSource = BindingSource.Body; + var bodyBinder = bodyProvider.GetBinder(context); + context.BindingInfo.BindingSource = BindingSource.ModelBinding; + var complexBinder = complexProvider.GetBinder(context); + + return new HybridModelBinder(bodyBinder, complexBinder); + } +} + +public class HybridModelBinder( + IModelBinder? bodyBinder, + IModelBinder? complexBinder +) : IModelBinder { + public async Task BindModelAsync(ModelBindingContext bindingContext) { + if (bodyBinder != null && bindingContext is + { IsTopLevelObject: true, HttpContext.Request.HasFormContentType: false }) { + bindingContext.BindingSource = BindingSource.Body; + await bodyBinder.BindModelAsync(bindingContext); + } + + if (complexBinder != null && !bindingContext.Result.IsModelSet) { + bindingContext.BindingSource = BindingSource.ModelBinding; + await complexBinder.BindModelAsync(bindingContext); + } + + if (bindingContext.Result.IsModelSet) bindingContext.Model = bindingContext.Result.Model; + } +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public class FromHybridAttribute : Attribute, IBindingSourceMetadata { + public BindingSource BindingSource => HybridBindingSource.Hybrid; +} + +public sealed class HybridBindingSource() : BindingSource("Hybrid", "Hybrid", true, true) { + public static readonly HybridBindingSource Hybrid = new(); + + public override bool CanAcceptDataFrom(BindingSource bindingSource) { + return bindingSource == this; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index ffefdae3..1acf9c6c 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -107,11 +107,16 @@ public static class ServiceExtensions { Type = SecuritySchemeType.Http, Scheme = "bearer" }); + options.AddSecurityDefinition("mastodon", new OpenApiSecurityScheme { + Name = "Authorization token", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer" + }); options.OperationFilter(); }); } - public static void AddSlidingWindowRateLimiter(this IServiceCollection services) { //TODO: separate limiter for authenticated users, partitioned by user id //TODO: ipv6 /64 subnet buckets @@ -153,16 +158,22 @@ public static class ServiceExtensions { return; //TODO: separate admin & user authorize attributes - var hasAuthorize = context.MethodInfo.DeclaringType.GetCustomAttributes(true) + var hasAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true) .OfType().Any() || context.MethodInfo.GetCustomAttributes(true) .OfType().Any(); + + var hasOauthAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType().Any() || + context.MethodInfo.GetCustomAttributes(true) + .OfType().Any(); - if (!hasAuthorize) return; + if (!hasAuthenticate && !hasOauthAuthenticate) return; + var schema = new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, - Id = "user" + Id = hasAuthenticate ? "user" : "mastodon" } }; diff --git a/Iceshrimp.Backend/Core/Helpers/JsonConverters.cs b/Iceshrimp.Backend/Core/Helpers/JsonConverters.cs new file mode 100644 index 00000000..e609f892 --- /dev/null +++ b/Iceshrimp.Backend/Core/Helpers/JsonConverters.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Iceshrimp.Backend.Core.Helpers; + +public class EnsureArrayConverter : JsonConverter> { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.StartArray) { + var list = new List(); + reader.Read(); + + while (reader.TokenType != JsonTokenType.EndArray) { + list.Add(JsonSerializer.Deserialize(ref reader, options) ?? + throw new InvalidOperationException()); + reader.Read(); + } + + return list; + } + + if (reader.TokenType == JsonTokenType.String) { + var str = JsonSerializer.Deserialize(ref reader, options) ?? + throw new InvalidOperationException(); + return [str]; + } + + throw new InvalidOperationException(); + } + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Helpers/MastodonOauthHelpers.cs b/Iceshrimp.Backend/Core/Helpers/MastodonOauthHelpers.cs new file mode 100644 index 00000000..ccc19507 --- /dev/null +++ b/Iceshrimp.Backend/Core/Helpers/MastodonOauthHelpers.cs @@ -0,0 +1,74 @@ +using Iceshrimp.Backend.Core.Middleware; + +namespace Iceshrimp.Backend.Core.Helpers; + +public static class MastodonOauthHelpers { + private static readonly List ReadScopes = [ + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses" + ]; + + private static readonly List WriteScopes = [ + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses" + ]; + + private static readonly List FollowScopes = [ + "read:follows", + "read:blocks", + "read:mutes", + "write:follows", + "write:blocks", + "write:mutes" + ]; + + public static IEnumerable ExpandScopes(IEnumerable scopes) { + var res = new List(); + foreach (var scope in scopes) { + if (scope == "read") + res.AddRange(ReadScopes); + if (scope == "write") + res.AddRange(WriteScopes); + if (scope == "follow") + res.AddRange(FollowScopes); + else { + res.Add(scope); + } + } + + return res.Distinct(); + } + + private static readonly List ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; + + public static bool ValidateRedirectUri(string uri) { + if (uri == "urn:ietf:wg:oauth:2.0:oob") return true; + try { + var proto = new Uri(uri).Scheme; + return !ForbiddenSchemes.Contains(proto); + } + catch { + return false; + } + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Middleware/AuthenticationMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/AuthenticationMiddleware.cs index 95e77125..1b35852b 100644 --- a/Iceshrimp.Backend/Core/Middleware/AuthenticationMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/AuthenticationMiddleware.cs @@ -19,7 +19,7 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware { } var token = header[7..]; - var session = await db.Sessions.Include(p => p.User).FirstOrDefaultAsync(p => p.Token == token); + var session = await db.Sessions.Include(p => p.User).FirstOrDefaultAsync(p => p.Token == token && p.Active); if (session == null) { await next(ctx); return; @@ -34,7 +34,7 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware { public class AuthenticateAttribute : Attribute; -public static class HttpContextExtensions { +public static partial class HttpContextExtensions { private const string Key = "session"; internal static void SetSession(this HttpContext ctx, Session session) { diff --git a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs index d79132b6..68d6d661 100644 --- a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs @@ -85,6 +85,14 @@ public class GracefulException(HttpStatusCode statusCode, string error, string m public static GracefulException NotFound(string message, string? details = null) { return new GracefulException(HttpStatusCode.NotFound, message, details); } + + public static GracefulException BadRequest(string message, string? details = null) { + return new GracefulException(HttpStatusCode.BadRequest, message, details); + } + + public static GracefulException RecordNotFound() { + return new GracefulException(HttpStatusCode.NotFound, "Record not found"); + } } public enum ExceptionVerbosity { diff --git a/Iceshrimp.Backend/Core/Middleware/OauthAuthenticationMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/OauthAuthenticationMiddleware.cs new file mode 100644 index 00000000..4415e777 --- /dev/null +++ b/Iceshrimp.Backend/Core/Middleware/OauthAuthenticationMiddleware.cs @@ -0,0 +1,66 @@ +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Helpers; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.EntityFrameworkCore; + +namespace Iceshrimp.Backend.Core.Middleware; + +public class OauthAuthenticationMiddleware(DatabaseContext db) : IMiddleware { + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { + var endpoint = ctx.Features.Get()?.Endpoint; + var attribute = endpoint?.Metadata.GetMetadata(); + + if (attribute != null) { + var request = ctx.Request; + var header = request.Headers.Authorization.ToString(); + if (!header.ToLowerInvariant().StartsWith("bearer ")) { + await next(ctx); + return; + } + + header = header[7..]; + var token = await db.OauthTokens + .Include(p => p.User) + .Include(p => p.App) + .FirstOrDefaultAsync(p => p.Token == header && p.Active); + + if (token == null) { + await next(ctx); + return; + } + + if (attribute.Scopes.Length > 0 && + attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)).Any()) { + await next(ctx); + return; + } + + ctx.SetOauthToken(token); + } + + await next(ctx); + } +} + +public class AuthenticateOauthAttribute(params string[] scopes) : Attribute { + public string[] Scopes = scopes; +} + +public static partial class HttpContextExtensions { + private const string MastodonKey = "masto-session"; + + internal static void SetOauthToken(this HttpContext ctx, OauthToken session) { + ctx.Items.Add(MastodonKey, session); + } + + public static OauthToken? GetOauthToken(this HttpContext ctx) { + ctx.Items.TryGetValue(MastodonKey, out var session); + return session as OauthToken; + } + + public static User? GetOauthUser(this HttpContext ctx) { + ctx.Items.TryGetValue(MastodonKey, out var session); + return (session as OauthToken)?.User; + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Middleware/OauthAuthorizationMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/OauthAuthorizationMiddleware.cs new file mode 100644 index 00000000..a02a0fb7 --- /dev/null +++ b/Iceshrimp.Backend/Core/Middleware/OauthAuthorizationMiddleware.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http.Features; + +namespace Iceshrimp.Backend.Core.Middleware; + +public class OauthAuthorizationMiddleware : IMiddleware { + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { + var endpoint = ctx.Features.Get()?.Endpoint; + var attribute = endpoint?.Metadata.GetMetadata(); + + if (attribute != null) + if (ctx.GetOauthToken() is not { Active: true }) + throw GracefulException.Forbidden("This method requires an authenticated user"); + + await next(ctx); + } +} + +public class AuthorizeOauthAttribute : Attribute; diff --git a/Iceshrimp.Backend/Iceshrimp.Backend.csproj b/Iceshrimp.Backend/Iceshrimp.Backend.csproj index 744fd339..692f42dc 100644 --- a/Iceshrimp.Backend/Iceshrimp.Backend.csproj +++ b/Iceshrimp.Backend/Iceshrimp.Backend.csproj @@ -14,41 +14,41 @@ - - - - - - - - + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + - - - - + + + + - + - + diff --git a/Iceshrimp.Backend/Startup.cs b/Iceshrimp.Backend/Startup.cs index b1b22854..cb840c43 100644 --- a/Iceshrimp.Backend/Startup.cs +++ b/Iceshrimp.Backend/Startup.cs @@ -1,7 +1,5 @@ using Asp.Versioning; using Iceshrimp.Backend.Core.Extensions; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerUI; using Vite.AspNetCore.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -14,7 +12,7 @@ builder.Configuration .AddIniFile(Environment.GetEnvironmentVariable("ICESHRIMP_CONFIG_OVERRIDES") ?? "configuration.overrides.ini", true, true); -builder.Services.AddControllers() +builder.Services.AddControllers(options => { options.ModelBinderProviders.AddHybridBindingProvider(); }) .AddNewtonsoftJson() //TODO: remove once dotNetRdf switches to System.Text.Json .AddMultiFormatter(); builder.Services.AddApiVersioning(options => {