[backend] Replace mastodon-specific middleware with modes triggered on MastodonApiControllerAttribute

This commit is contained in:
Laura Hausmann 2024-02-05 21:08:20 +01:00
parent cb749c94e6
commit e31a0719f4
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
16 changed files with 155 additions and 165 deletions

View file

@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Mvc;
namespace Iceshrimp.Backend.Controllers.Mastodon.Attributes;
public class MastodonApiControllerAttribute : ApiControllerAttribute;

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
@ -10,21 +11,20 @@ using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
[ApiController] [MastodonApiController]
[Tags("Mastodon")]
[Route("/api/v1/accounts")] [Route("/api/v1/accounts")]
[AuthenticateOauth] [Authenticate]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces("application/json")] [Produces("application/json")]
public class MastodonAccountController(DatabaseContext db, UserRenderer userRenderer) : Controller { public class MastodonAccountController(DatabaseContext db, UserRenderer userRenderer) : Controller {
[AuthorizeOauth("read:accounts")] [Authorize("read:accounts")]
[HttpGet("verify_credentials")] [HttpGet("verify_credentials")]
[Produces("application/json")] [Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> VerifyUserCredentials() { public async Task<IActionResult> VerifyUserCredentials() {
var user = HttpContext.GetOauthUser() ?? throw new GracefulException("Failed to get user from HttpContext"); var user = HttpContext.GetUser() ?? throw new GracefulException("Failed to get user from HttpContext");
var res = await userRenderer.RenderAsync(user); var res = await userRenderer.RenderAsync(user);
return Ok(res); return Ok(res);
} }

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
@ -10,13 +11,12 @@ using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
[ApiController] [MastodonApiController]
[Tags("Mastodon")]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces("application/json")] [Produces("application/json")]
public class MastodonAuthController(DatabaseContext db) : Controller { public class MastodonAuthController(DatabaseContext db) : Controller {
[HttpGet("/api/v1/apps/verify_credentials")] [HttpGet("/api/v1/apps/verify_credentials")]
[AuthenticateOauth] [Authenticate]
[Produces("application/json")] [Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.VerifyAppCredentialsResponse))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.VerifyAppCredentialsResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
@ -10,15 +11,14 @@ using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
[ApiController] [MastodonApiController]
[Tags("Mastodon")]
[Route("/api/v1/statuses")] [Route("/api/v1/statuses")]
[AuthenticateOauth] [Authenticate]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces("application/json")] [Produces("application/json")]
public class MastodonStatusController(DatabaseContext db, NoteRenderer noteRenderer) : Controller { public class MastodonStatusController(DatabaseContext db, NoteRenderer noteRenderer) : Controller {
[HttpGet("{id}")] [HttpGet("{id}")]
[AuthenticateOauth("read:statuses")] [Authenticate("read:statuses")]
[Produces("application/json")] [Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Account))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]

View file

@ -1,4 +1,5 @@
using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
@ -11,22 +12,21 @@ using Microsoft.AspNetCore.RateLimiting;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
[ApiController] [MastodonApiController]
[Tags("Mastodon")]
[Route("/api/v1/timelines")] [Route("/api/v1/timelines")]
[AuthenticateOauth] [Authenticate]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces("application/json")] [Produces("application/json")]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRenderer) : Controller { public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRenderer) : Controller {
[AuthorizeOauth("read:statuses")] [Authorize("read:statuses")]
[HttpGet("home")] [HttpGet("home")]
[Produces("application/json")] [Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Status>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Status>))]
public async Task<IActionResult> GetHomeTimeline(PaginationQuery query) { public async Task<IActionResult> GetHomeTimeline(PaginationQuery query) {
var user = HttpContext.GetOauthUser() ?? throw new GracefulException("Failed to get user from HttpContext"); var user = HttpContext.GetUser() ?? throw new GracefulException("Failed to get user from HttpContext");
var res = await db.Notes var res = await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.FilterByFollowingAndOwn(user) .FilterByFollowingAndOwn(user)
@ -41,12 +41,12 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen
return Ok(res); return Ok(res);
} }
[AuthorizeOauth("read:statuses")] [Authorize("read:statuses")]
[HttpGet("public")] [HttpGet("public")]
[Produces("application/json")] [Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Status>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<Status>))]
public async Task<IActionResult> GetPublicTimeline(PaginationQuery query) { public async Task<IActionResult> GetPublicTimeline(PaginationQuery query) {
var user = HttpContext.GetOauthUser() ?? throw new GracefulException("Failed to get user from HttpContext"); var user = HttpContext.GetUser() ?? throw new GracefulException("Failed to get user from HttpContext");
var res = await db.Notes var res = await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()

View file

@ -1,8 +1,13 @@
using System.Text.Json.Serialization;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas;
public class MastodonErrorResponse { public class MastodonErrorResponse {
[J("error")] public required string Error { get; set; } [J("error")] public required string Error { get; set; }
[J("error_description")] public required string? Description { get; set; }
[J("error_description")]
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required string? Description { get; set; }
} }

View file

@ -32,7 +32,6 @@ public static class ServiceExtensions {
services.AddScoped<WebFingerService>(); services.AddScoped<WebFingerService>();
services.AddScoped<AuthorizedFetchMiddleware>(); services.AddScoped<AuthorizedFetchMiddleware>();
services.AddScoped<AuthenticationMiddleware>(); services.AddScoped<AuthenticationMiddleware>();
services.AddScoped<OauthAuthenticationMiddleware>();
//TODO: make this prettier //TODO: make this prettier
services.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>(); services.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>();
@ -46,7 +45,6 @@ public static class ServiceExtensions {
services.AddSingleton<ErrorHandlerMiddleware>(); services.AddSingleton<ErrorHandlerMiddleware>();
services.AddSingleton<RequestBufferingMiddleware>(); services.AddSingleton<RequestBufferingMiddleware>();
services.AddSingleton<AuthorizationMiddleware>(); services.AddSingleton<AuthorizationMiddleware>();
services.AddSingleton<OauthAuthorizationMiddleware>();
services.AddSingleton<RequestVerificationMiddleware>(); services.AddSingleton<RequestVerificationMiddleware>();
// Hosted services = long running background tasks // Hosted services = long running background tasks

View file

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
@ -11,6 +12,23 @@ public static class SwaggerGenOptionsExtensions {
public static void AddOperationFilters(this SwaggerGenOptions options) { public static void AddOperationFilters(this SwaggerGenOptions options) {
options.OperationFilter<AuthorizeCheckOperationFilter>(); options.OperationFilter<AuthorizeCheckOperationFilter>();
options.OperationFilter<HybridRequestOperationFilter>(); options.OperationFilter<HybridRequestOperationFilter>();
options.OperationFilter<MastodonApiControllerOperationFilter>();
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
private class MastodonApiControllerOperationFilter : IOperationFilter {
public void Apply(OpenApiOperation operation, OperationFilterContext context) {
if (context.MethodInfo.DeclaringType is null)
return;
var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<MastodonApiControllerAttribute>().Any();
if (!isMastodonController) return;
operation.Tags = [new OpenApiTag { Name = "Mastodon" }];
}
} }
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
@ -26,17 +44,15 @@ public static class SwaggerGenOptionsExtensions {
context.MethodInfo.GetCustomAttributes(true) context.MethodInfo.GetCustomAttributes(true)
.OfType<AuthenticateAttribute>().Any(); .OfType<AuthenticateAttribute>().Any();
var hasOauthAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true) var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<AuthenticateOauthAttribute>().Any() || .OfType<MastodonApiControllerAttribute>().Any();
context.MethodInfo.GetCustomAttributes(true)
.OfType<AuthenticateOauthAttribute>().Any();
if (!hasAuthenticate && !hasOauthAuthenticate) return; if (!hasAuthenticate) return;
var schema = new OpenApiSecurityScheme { var schema = new OpenApiSecurityScheme {
Reference = new OpenApiReference { Reference = new OpenApiReference {
Type = ReferenceType.SecurityScheme, Type = ReferenceType.SecurityScheme,
Id = hasAuthenticate ? "user" : "mastodon" Id = isMastodonController ? "mastodon" : "user"
} }
}; };

View file

@ -14,8 +14,6 @@ public static class WebApplicationExtensions {
.UseMiddleware<RequestBufferingMiddleware>() .UseMiddleware<RequestBufferingMiddleware>()
.UseMiddleware<AuthenticationMiddleware>() .UseMiddleware<AuthenticationMiddleware>()
.UseMiddleware<AuthorizationMiddleware>() .UseMiddleware<AuthorizationMiddleware>()
.UseMiddleware<OauthAuthenticationMiddleware>()
.UseMiddleware<OauthAuthorizationMiddleware>()
.UseMiddleware<AuthorizedFetchMiddleware>(); .UseMiddleware<AuthorizedFetchMiddleware>();
} }

View file

@ -1,13 +1,14 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Microsoft.AspNetCore.Http.Features; using Iceshrimp.Backend.Core.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware { public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
var endpoint = ctx.Features.Get<IEndpointFeature>()?.Endpoint; var endpoint = ctx.GetEndpoint();
var attribute = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>(); var attribute = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>();
if (attribute != null) { if (attribute != null) {
@ -19,27 +20,54 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware {
} }
var token = header[7..]; var token = header[7..];
var session = await db.Sessions
.Include(p => p.User)
.ThenInclude(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
if (session == null) { var isMastodon = endpoint?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null;
await next(ctx); if (isMastodon) {
return; var oauthToken = await db.OauthTokens
.Include(p => p.User)
.ThenInclude(p => p.UserProfile)
.Include(p => p.App)
.FirstOrDefaultAsync(p => p.Token == header && p.Active);
if (oauthToken == null) {
await next(ctx);
return;
}
if (attribute.Scopes.Length > 0 &&
attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(oauthToken.Scopes)).Any()) {
await next(ctx);
return;
}
ctx.SetOauthToken(oauthToken);
} }
else {
var session = await db.Sessions
.Include(p => p.User)
.ThenInclude(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
ctx.SetSession(session); if (session == null) {
await next(ctx);
return;
}
ctx.SetSession(session);
}
} }
await next(ctx); await next(ctx);
} }
} }
public class AuthenticateAttribute : Attribute; public class AuthenticateAttribute(params string[] scopes) : Attribute {
public readonly string[] Scopes = scopes;
}
public static partial class HttpContextExtensions { public static class HttpContextExtensions {
private const string Key = "session"; private const string Key = "session";
private const string MastodonKey = "masto-session";
internal static void SetSession(this HttpContext ctx, Session session) { internal static void SetSession(this HttpContext ctx, Session session) {
ctx.Items.Add(Key, session); ctx.Items.Add(Key, session);
@ -50,8 +78,21 @@ public static partial class HttpContextExtensions {
return session as Session; return session as 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;
}
//TODO: Is it faster to check for the MastodonApiControllerAttribute here?
public static User? GetUser(this HttpContext ctx) { public static User? GetUser(this HttpContext ctx) {
ctx.Items.TryGetValue(Key, out var session); if (ctx.Items.TryGetValue(Key, out var session))
return (session as Session)?.User; return (session as Session)?.User;
return ctx.Items.TryGetValue(MastodonKey, out var token)
? (token as OauthToken)?.User
: null;
} }
} }

View file

@ -1,19 +1,33 @@
using Microsoft.AspNetCore.Http.Features; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Core.Helpers;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class AuthorizationMiddleware : IMiddleware { public class AuthorizationMiddleware : IMiddleware {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
var endpoint = ctx.Features.Get<IEndpointFeature>()?.Endpoint; var endpoint = ctx.GetEndpoint();
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
if (attribute != null) if (attribute != null) {
if (ctx.GetSession() is not { Active: true }) var isMastodon = endpoint?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null;
if (isMastodon) {
var token = ctx.GetOauthToken();
if (token is not { Active: true })
throw GracefulException.Unauthorized("This method requires an authenticated user");
if (attribute.Scopes.Length > 0 &&
attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)).Any())
throw GracefulException.Forbidden("This action is outside the authorized scopes");
}
else if (ctx.GetSession() is not { Active: true }) {
throw GracefulException.Forbidden("This method requires an authenticated user"); throw GracefulException.Forbidden("This method requires an authenticated user");
}
}
await next(ctx); await next(ctx);
} }
} }
public class AuthorizeAttribute : Attribute; public class AuthorizeAttribute(params string[] scopes) : Attribute {
//TODO: oauth scopes? public readonly string[] Scopes = scopes;
}

View file

@ -5,7 +5,6 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.ActivityPub; using Iceshrimp.Backend.Core.Federation.ActivityPub;
using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -19,8 +18,7 @@ public class AuthorizedFetchMiddleware(
UserService userSvc, UserService userSvc,
ILogger<AuthorizedFetchMiddleware> logger) : IMiddleware { ILogger<AuthorizedFetchMiddleware> logger) : IMiddleware {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
var endpoint = ctx.Features.Get<IEndpointFeature>()?.Endpoint; var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<AuthorizedFetchAttribute>();
var attribute = endpoint?.Metadata.GetMetadata<AuthorizedFetchAttribute>();
if (attribute != null && config.Value.AuthorizedFetch) { if (attribute != null && config.Value.AuthorizedFetch) {
var request = ctx.Request; var request = ctx.Request;

View file

@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net; using System.Net;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Schemas; using Iceshrimp.Backend.Controllers.Schemas;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -24,6 +26,8 @@ public class ErrorHandlerMiddleware(IOptions<Config.SecuritySection> options, IL
var logger = loggerFactory.CreateLogger(type); var logger = loggerFactory.CreateLogger(type);
var verbosity = options.Value.ExceptionVerbosity; var verbosity = options.Value.ExceptionVerbosity;
var isMastodon = ctx.GetEndpoint()?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null;
if (e is GracefulException ce) { if (e is GracefulException ce) {
if (ce.StatusCode == HttpStatusCode.Accepted) { if (ce.StatusCode == HttpStatusCode.Accepted) {
ctx.Response.StatusCode = (int)ce.StatusCode; ctx.Response.StatusCode = (int)ce.StatusCode;
@ -34,15 +38,23 @@ public class ErrorHandlerMiddleware(IOptions<Config.SecuritySection> options, IL
if (verbosity > ExceptionVerbosity.Basic && ce.OverrideBasic) if (verbosity > ExceptionVerbosity.Basic && ce.OverrideBasic)
verbosity = ExceptionVerbosity.Basic; verbosity = ExceptionVerbosity.Basic;
ctx.Response.StatusCode = (int)ce.StatusCode; ctx.Response.StatusCode = (int)ce.StatusCode;
await ctx.Response.WriteAsJsonAsync(new ErrorResponse { ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
StatusCode = ctx.Response.StatusCode,
Error = verbosity >= ExceptionVerbosity.Basic ? ce.Error : ce.StatusCode.ToString(), if (isMastodon)
Message = verbosity >= ExceptionVerbosity.Basic ? ce.Message : null, await ctx.Response.WriteAsJsonAsync(new MastodonErrorResponse {
Details = verbosity == ExceptionVerbosity.Full ? ce.Details : null, Error = verbosity >= ExceptionVerbosity.Basic ? ce.Message : ce.StatusCode.ToString(),
Source = verbosity == ExceptionVerbosity.Full ? type : null, Description = verbosity >= ExceptionVerbosity.Basic ? ce.Details : null
RequestId = ctx.TraceIdentifier });
}); else
await ctx.Response.WriteAsJsonAsync(new ErrorResponse {
StatusCode = ctx.Response.StatusCode,
Error = verbosity >= ExceptionVerbosity.Basic ? ce.Error : ce.StatusCode.ToString(),
Message = verbosity >= ExceptionVerbosity.Basic ? ce.Message : null,
Details = verbosity == ExceptionVerbosity.Full ? ce.Details : null,
Source = verbosity == ExceptionVerbosity.Full ? type : null,
RequestId = ctx.TraceIdentifier
});
if (!ce.SuppressLog) { if (!ce.SuppressLog) {
if (ce.Details != null) if (ce.Details != null)
@ -120,7 +132,8 @@ public class GracefulException(
} }
/// <summary> /// <summary>
/// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger returning 410 Gone) /// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger
/// returning 410 Gone)
/// </summary> /// </summary>
public static GracefulException Accepted(string message) { public static GracefulException Accepted(string message) {
return new GracefulException(HttpStatusCode.Accepted, message); return new GracefulException(HttpStatusCode.Accepted, message);

View file

@ -1,67 +0,0 @@
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)
.ThenInclude(p => p.UserProfile)
.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 readonly 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

@ -1,26 +0,0 @@
using Iceshrimp.Backend.Core.Helpers;
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) {
var token = ctx.GetOauthToken();
if (token is not { Active: true })
throw GracefulException.Unauthorized("This method requires an authenticated user");
if (attribute.Scopes.Length > 0 &&
attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)).Any())
throw GracefulException.Forbidden("This action is outside the authorized scopes");
}
await next(ctx);
}
}
public class AuthorizeOauthAttribute(params string[] scopes) : Attribute {
public readonly string[] Scopes = scopes;
}

View file

@ -1,14 +1,9 @@
using Microsoft.AspNetCore.Http.Features;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class RequestBufferingMiddleware : IMiddleware { public class RequestBufferingMiddleware : IMiddleware {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
var endpoint = ctx.Features.Get<IEndpointFeature>()?.Endpoint; var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<EnableRequestBufferingAttribute>();
var attribute = endpoint?.Metadata.GetMetadata<EnableRequestBufferingAttribute>();
if (attribute != null) ctx.Request.EnableBuffering(attribute.MaxLength); if (attribute != null) ctx.Request.EnableBuffering(attribute.MaxLength);
await next(ctx); await next(ctx);
} }
} }