Mastodon client API foundations

This commit is contained in:
Laura Hausmann 2024-01-29 03:25:39 +01:00
parent 3f333b0c5d
commit cec4abd841
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
13 changed files with 434 additions and 32 deletions

View file

@ -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.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@ -8,4 +14,55 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces("application/json")] [Produces("application/json")]
[Route("/api/v1")] [Route("/api/v1")]
public class MastodonAuthController : Controller { } 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<IActionResult> 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);
}
}

View file

@ -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<string> _scopes = ["read"];
public List<string> RedirectUris = [];
[B(Name = "scopes")]
[J("scopes")]
[JC(typeof(EnsureArrayConverter))]
public List<string> 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; }
}
}

View file

@ -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; }
}

View file

@ -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<IModelBinderProvider> 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;
}
}

View file

@ -107,11 +107,16 @@ public static class ServiceExtensions {
Type = SecuritySchemeType.Http, Type = SecuritySchemeType.Http,
Scheme = "bearer" Scheme = "bearer"
}); });
options.AddSecurityDefinition("mastodon", new OpenApiSecurityScheme {
Name = "Authorization token",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "bearer"
});
options.OperationFilter<AuthorizeCheckOperationFilter>(); options.OperationFilter<AuthorizeCheckOperationFilter>();
}); });
} }
public static void AddSlidingWindowRateLimiter(this IServiceCollection services) { public static void AddSlidingWindowRateLimiter(this IServiceCollection services) {
//TODO: separate limiter for authenticated users, partitioned by user id //TODO: separate limiter for authenticated users, partitioned by user id
//TODO: ipv6 /64 subnet buckets //TODO: ipv6 /64 subnet buckets
@ -153,16 +158,22 @@ public static class ServiceExtensions {
return; return;
//TODO: separate admin & user authorize attributes //TODO: separate admin & user authorize attributes
var hasAuthorize = context.MethodInfo.DeclaringType.GetCustomAttributes(true) var hasAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<AuthenticateAttribute>().Any() || .OfType<AuthenticateAttribute>().Any() ||
context.MethodInfo.GetCustomAttributes(true) context.MethodInfo.GetCustomAttributes(true)
.OfType<AuthenticateAttribute>().Any(); .OfType<AuthenticateAttribute>().Any();
if (!hasAuthorize) return; var hasOauthAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<AuthenticateOauthAttribute>().Any() ||
context.MethodInfo.GetCustomAttributes(true)
.OfType<AuthenticateOauthAttribute>().Any();
if (!hasAuthenticate && !hasOauthAuthenticate) return;
var schema = new OpenApiSecurityScheme { var schema = new OpenApiSecurityScheme {
Reference = new OpenApiReference { Reference = new OpenApiReference {
Type = ReferenceType.SecurityScheme, Type = ReferenceType.SecurityScheme,
Id = "user" Id = hasAuthenticate ? "user" : "mastodon"
} }
}; };

View file

@ -0,0 +1,34 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Iceshrimp.Backend.Core.Helpers;
public class EnsureArrayConverter : JsonConverter<List<string>> {
public override List<string> Read(ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options) {
if (reader.TokenType == JsonTokenType.StartArray) {
var list = new List<string>();
reader.Read();
while (reader.TokenType != JsonTokenType.EndArray) {
list.Add(JsonSerializer.Deserialize<string>(ref reader, options) ??
throw new InvalidOperationException());
reader.Read();
}
return list;
}
if (reader.TokenType == JsonTokenType.String) {
var str = JsonSerializer.Deserialize<string>(ref reader, options) ??
throw new InvalidOperationException();
return [str];
}
throw new InvalidOperationException();
}
public override void Write(Utf8JsonWriter writer, List<string> value, JsonSerializerOptions options) {
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,74 @@
using Iceshrimp.Backend.Core.Middleware;
namespace Iceshrimp.Backend.Core.Helpers;
public static class MastodonOauthHelpers {
private static readonly List<string> 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<string> 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<string> FollowScopes = [
"read:follows",
"read:blocks",
"read:mutes",
"write:follows",
"write:blocks",
"write:mutes"
];
public static IEnumerable<string> ExpandScopes(IEnumerable<string> scopes) {
var res = new List<string>();
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<string> 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;
}
}
}

View file

@ -19,7 +19,7 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware {
} }
var token = header[7..]; 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) { if (session == null) {
await next(ctx); await next(ctx);
return; return;
@ -34,7 +34,7 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware {
public class AuthenticateAttribute : Attribute; public class AuthenticateAttribute : Attribute;
public static class HttpContextExtensions { public static partial class HttpContextExtensions {
private const string Key = "session"; private const string Key = "session";
internal static void SetSession(this HttpContext ctx, Session session) { internal static void SetSession(this HttpContext ctx, Session session) {

View file

@ -85,6 +85,14 @@ public class GracefulException(HttpStatusCode statusCode, string error, string m
public static GracefulException NotFound(string message, string? details = null) { public static GracefulException NotFound(string message, string? details = null) {
return new GracefulException(HttpStatusCode.NotFound, message, details); 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 { public enum ExceptionVerbosity {

View file

@ -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<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<AuthenticateOauthAttribute>();
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;
}
}

View file

@ -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<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeOauthAttribute>();
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;

View file

@ -1,7 +1,5 @@
using Asp.Versioning; using Asp.Versioning;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerUI;
using Vite.AspNetCore.Extensions; using Vite.AspNetCore.Extensions;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -14,7 +12,7 @@ builder.Configuration
.AddIniFile(Environment.GetEnvironmentVariable("ICESHRIMP_CONFIG_OVERRIDES") ?? "configuration.overrides.ini", .AddIniFile(Environment.GetEnvironmentVariable("ICESHRIMP_CONFIG_OVERRIDES") ?? "configuration.overrides.ini",
true, true); true, true);
builder.Services.AddControllers() builder.Services.AddControllers(options => { options.ModelBinderProviders.AddHybridBindingProvider(); })
.AddNewtonsoftJson() //TODO: remove once dotNetRdf switches to System.Text.Json .AddNewtonsoftJson() //TODO: remove once dotNetRdf switches to System.Text.Json
.AddMultiFormatter(); .AddMultiFormatter();
builder.Services.AddApiVersioning(options => { builder.Services.AddApiVersioning(options => {