From ba0e041bad3d823a7d5b315a88634ea974f73c9f Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Sat, 24 Feb 2024 22:03:36 +0100 Subject: [PATCH] [backend/core] Add 401/403 response examples programmatically --- .../Controllers/AdminController.cs | 2 - .../Controllers/AuthController.cs | 3 +- .../Controllers/Mastodon/AccountController.cs | 16 ---- .../Controllers/Mastodon/ListController.cs | 2 - .../Controllers/Mastodon/MediaController.cs | 2 - .../Controllers/Mastodon/StatusController.cs | 8 -- .../Mastodon/TimelineController.cs | 2 - .../Controllers/NoteController.cs | 2 - .../Controllers/TimelineController.cs | 2 - .../Core/Extensions/ServiceExtensions.cs | 8 -- .../Extensions/SwaggerGenOptionsExtensions.cs | 89 +++++++++++++++++-- .../Middleware/AuthorizationMiddleware.cs | 2 +- 12 files changed, 83 insertions(+), 55 deletions(-) diff --git a/Iceshrimp.Backend/Controllers/AdminController.cs b/Iceshrimp.Backend/Controllers/AdminController.cs index 2b011f82..8ed5bc37 100644 --- a/Iceshrimp.Backend/Controllers/AdminController.cs +++ b/Iceshrimp.Backend/Controllers/AdminController.cs @@ -15,8 +15,6 @@ namespace Iceshrimp.Backend.Controllers; [Authorize("role:admin")] [ApiController] [Route("/api/v1/iceshrimp/admin")] -[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] -[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor", Justification = "We only have a DatabaseContext in our DI pool, not the base type")] public class AdminController(DatabaseContext db, ActivityPubController apController) : ControllerBase diff --git a/Iceshrimp.Backend/Controllers/AuthController.cs b/Iceshrimp.Backend/Controllers/AuthController.cs index e73cd317..2106bf7e 100644 --- a/Iceshrimp.Backend/Controllers/AuthController.cs +++ b/Iceshrimp.Backend/Controllers/AuthController.cs @@ -115,7 +115,6 @@ public class AuthController(DatabaseContext db, UserService userSvc) : Controlle [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Argon2 is execution time-heavy by design")] public async Task ChangePassword([FromBody] ChangePasswordRequest request) @@ -124,7 +123,7 @@ public class AuthController(DatabaseContext db, UserService userSvc) : Controlle var userProfile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user); if (userProfile is not { Password: not null }) throw new GracefulException("userProfile?.Password was null"); if (!AuthHelpers.ComparePassword(request.OldPassword, userProfile.Password)) - throw GracefulException.Forbidden("old_password is invalid"); + throw GracefulException.BadRequest("old_password is invalid"); userProfile.Password = AuthHelpers.HashPassword(request.NewPassword); await db.SaveChangesAsync(); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs index d9f02daa..cbaca348 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs @@ -33,8 +33,6 @@ public class AccountController( [HttpGet("verify_credentials")] [Authorize("read:accounts")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public async Task VerifyUserCredentials() { var user = HttpContext.GetUserOrFail(); @@ -56,8 +54,6 @@ public class AccountController( [HttpPost("{id}/follow")] [Authorize("write:follows")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] //TODO: [FromHybrid] request (bool reblogs, bool notify, bool languages) public async Task FollowUser(string id) { @@ -108,8 +104,6 @@ public class AccountController( [HttpPost("{id}/unfollow")] [Authorize("write:follows")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public async Task UnfollowUser(string id) { var user = HttpContext.GetUserOrFail(); @@ -149,8 +143,6 @@ public class AccountController( [HttpGet("relationships")] [Authorize("read:follows")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity[]))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public async Task GetRelationships([FromQuery(Name = "id")] List ids) { var user = HttpContext.GetUserOrFail(); @@ -186,8 +178,6 @@ public class AccountController( [Authorize("read:statuses")] [LinkPagination(20, 40)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public async Task GetUserStatuses( string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query ) @@ -275,8 +265,6 @@ public class AccountController( [Authorize("read:follows")] [LinkPagination(40, 80)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public async Task GetFollowRequests(MastodonPaginationQuery query) { var user = HttpContext.GetUserOrFail(); @@ -293,8 +281,6 @@ public class AccountController( [HttpPost("/api/v1/follow_requests/{id}/authorize")] [Authorize("write:follows")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task AcceptFollowRequest(string id) { @@ -338,8 +324,6 @@ public class AccountController( [HttpPost("/api/v1/follow_requests/{id}/reject")] [Authorize("write:follows")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task RejectFollowRequest(string id) { diff --git a/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs b/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs index bbc72094..487698cd 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs @@ -23,8 +23,6 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableRateLimiting("sliding")] [EnableCors("mastodon")] [Produces(MediaTypeNames.Application.Json)] -[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] -[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public class ListController(DatabaseContext db, UserRenderer userRenderer) : ControllerBase { [HttpGet] diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs index 952ac28e..2e8f5d1a 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs @@ -17,8 +17,6 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableRateLimiting("sliding")] [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] -[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] -[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public class MediaController(DriveService driveSvc) : ControllerBase { [HttpPost("/api/v1/media")] diff --git a/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs b/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs index b98fa5e2..fc7628e6 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs @@ -85,8 +85,6 @@ public class StatusController( [HttpPost("{id}/favourite")] [Authorize("write:favourites")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task LikeNote(string id) { @@ -104,8 +102,6 @@ public class StatusController( [HttpPost("{id}/unfavourite")] [Authorize("write:favourites")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task UnlikeNote(string id) { @@ -123,8 +119,6 @@ public class StatusController( [HttpPost("{id}/reblog")] [Authorize("write:favourites")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task Renote(string id, [FromHybrid] string? visibility) { @@ -153,8 +147,6 @@ public class StatusController( [HttpPost("{id}/unreblog")] [Authorize("write:favourites")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task UndoRenote(string id) { diff --git a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs index d90bf739..e883c957 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs @@ -23,8 +23,6 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableRateLimiting("sliding")] [EnableCors("mastodon")] [Produces(MediaTypeNames.Application.Json)] -[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] -[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, IDistributedCache cache) : ControllerBase { [Authorize("read:statuses")] diff --git a/Iceshrimp.Backend/Controllers/NoteController.cs b/Iceshrimp.Backend/Controllers/NoteController.cs index 134286ef..52553581 100644 --- a/Iceshrimp.Backend/Controllers/NoteController.cs +++ b/Iceshrimp.Backend/Controllers/NoteController.cs @@ -41,8 +41,6 @@ public class NoteController(DatabaseContext db, NoteService noteSvc, NoteRendere [Authorize] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] public async Task CreateNote(NoteCreateRequest request) { var user = HttpContext.GetUserOrFail(); diff --git a/Iceshrimp.Backend/Controllers/TimelineController.cs b/Iceshrimp.Backend/Controllers/TimelineController.cs index f501c2e4..5d648517 100644 --- a/Iceshrimp.Backend/Controllers/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/TimelineController.cs @@ -23,8 +23,6 @@ public class TimelineController(DatabaseContext db, IDistributedCache cache, Not [Authenticate] [Authorize] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task GetHomeTimeline(PaginationQuery pq) { diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index 4d0bc3dc..e3b65942 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -147,14 +147,6 @@ public static class ServiceExtensions Type = SecuritySchemeType.Http, Scheme = "bearer" }); - options.AddSecurityDefinition("admin", - new OpenApiSecurityScheme - { - Name = "Authorization token", - In = ParameterLocation.Header, - Type = SecuritySchemeType.Http, - Scheme = "bearer" - }); options.AddSecurityDefinition("mastodon", new OpenApiSecurityScheme { diff --git a/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs b/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs index a2229492..46a3810c 100644 --- a/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs @@ -3,6 +3,7 @@ using System.Reflection; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -45,19 +46,25 @@ public static class SwaggerGenOptionsExtensions if (context.MethodInfo.DeclaringType is null) return; - //TODO: separate admin & user authorize attributes - var hasAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true) - .OfType() - .Any() || - context.MethodInfo.GetCustomAttributes(true) - .OfType() - .Any(); + var authenticateAttribute = context.MethodInfo.GetCustomAttributes(true) + .OfType() + .FirstOrDefault() ?? + context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType() + .FirstOrDefault(); + + if (authenticateAttribute == null) return; var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true) .OfType() .Any(); - if (!hasAuthenticate) return; + var authorizeAttribute = context.MethodInfo.GetCustomAttributes(true) + .OfType() + .FirstOrDefault() ?? + context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType() + .FirstOrDefault(); var schema = new OpenApiSecurityScheme { @@ -68,6 +75,72 @@ public static class SwaggerGenOptionsExtensions }; operation.Security = new List { new() { [schema] = Array.Empty() } }; + + if (authorizeAttribute == null) return; + + const string web401 = + """ + { + "statusCode": 401, + "error": "Unauthorized", + "message": "This method requires an authenticated user" + } + """; + + const string web403 = + """ + { + "statusCode": 403, + "error": "Forbidden", + "message": "This action is outside the authorized scopes" + } + """; + + const string masto401 = + """ + { + "error": "This method requires an authenticated user" + } + """; + + const string masto403 = + """ + { + "message": "This action is outside the authorized scopes" + } + """; + + var example401 = new OpenApiString(isMastodonController ? masto401 : web401); + + var res401 = new OpenApiResponse + { + Description = "Unauthorized", + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Example = example401 } } + } + }; + + operation.Responses.Remove("401"); + operation.Responses.Add("401", res401); + + if (authorizeAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 } && + authenticateAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 }) + return; + + operation.Responses.Remove("403"); + + var example403 = new OpenApiString(isMastodonController ? masto403 : web403); + + var res403 = new OpenApiResponse + { + Description = "Forbidden", + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Example = example403 } } + } + }; + operation.Responses.Add("403", res403); } } diff --git a/Iceshrimp.Backend/Core/Middleware/AuthorizationMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/AuthorizationMiddleware.cs index ddbf3133..492b537c 100644 --- a/Iceshrimp.Backend/Core/Middleware/AuthorizationMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/AuthorizationMiddleware.cs @@ -31,7 +31,7 @@ public class AuthorizationMiddleware : IMiddleware { var session = ctx.GetSession(); if (session is not { Active: true }) - throw GracefulException.Forbidden("This method requires an authenticated user"); + throw GracefulException.Unauthorized("This method requires an authenticated user"); if (attribute.AdminRole && !session.User.IsAdmin) throw GracefulException.Forbidden("This action is outside the authorized scopes"); if (attribute.ModeratorRole && session.User is { IsAdmin: false, IsModerator: false })