[backend/core] Add 401/403 response examples programmatically

This commit is contained in:
Laura Hausmann 2024-02-24 22:03:36 +01:00
parent 72bc5e1090
commit ba0e041bad
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
12 changed files with 83 additions and 55 deletions

View file

@ -15,8 +15,6 @@ namespace Iceshrimp.Backend.Controllers;
[Authorize("role:admin")] [Authorize("role:admin")]
[ApiController] [ApiController]
[Route("/api/v1/iceshrimp/admin")] [Route("/api/v1/iceshrimp/admin")]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor", [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
Justification = "We only have a DatabaseContext in our DI pool, not the base type")] Justification = "We only have a DatabaseContext in our DI pool, not the base type")]
public class AdminController(DatabaseContext db, ActivityPubController apController) : ControllerBase public class AdminController(DatabaseContext db, ActivityPubController apController) : ControllerBase

View file

@ -115,7 +115,6 @@ public class AuthController(DatabaseContext db, UserService userSvc) : Controlle
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
[SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action",
Justification = "Argon2 is execution time-heavy by design")] Justification = "Argon2 is execution time-heavy by design")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request) public async Task<IActionResult> 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); 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 (userProfile is not { Password: not null }) throw new GracefulException("userProfile?.Password was null");
if (!AuthHelpers.ComparePassword(request.OldPassword, userProfile.Password)) 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); userProfile.Password = AuthHelpers.HashPassword(request.NewPassword);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -33,8 +33,6 @@ public class AccountController(
[HttpGet("verify_credentials")] [HttpGet("verify_credentials")]
[Authorize("read:accounts")] [Authorize("read:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> VerifyUserCredentials() public async Task<IActionResult> VerifyUserCredentials()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -56,8 +54,6 @@ public class AccountController(
[HttpPost("{id}/follow")] [HttpPost("{id}/follow")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [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) //TODO: [FromHybrid] request (bool reblogs, bool notify, bool languages)
public async Task<IActionResult> FollowUser(string id) public async Task<IActionResult> FollowUser(string id)
{ {
@ -108,8 +104,6 @@ public class AccountController(
[HttpPost("{id}/unfollow")] [HttpPost("{id}/unfollow")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UnfollowUser(string id) public async Task<IActionResult> UnfollowUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -149,8 +143,6 @@ public class AccountController(
[HttpGet("relationships")] [HttpGet("relationships")]
[Authorize("read:follows")] [Authorize("read:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity[]))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity[]))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetRelationships([FromQuery(Name = "id")] List<string> ids) public async Task<IActionResult> GetRelationships([FromQuery(Name = "id")] List<string> ids)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -186,8 +178,6 @@ public class AccountController(
[Authorize("read:statuses")] [Authorize("read:statuses")]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetUserStatuses( public async Task<IActionResult> GetUserStatuses(
string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query
) )
@ -275,8 +265,6 @@ public class AccountController(
[Authorize("read:follows")] [Authorize("read:follows")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetFollowRequests(MastodonPaginationQuery query) public async Task<IActionResult> GetFollowRequests(MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -293,8 +281,6 @@ public class AccountController(
[HttpPost("/api/v1/follow_requests/{id}/authorize")] [HttpPost("/api/v1/follow_requests/{id}/authorize")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> AcceptFollowRequest(string id) public async Task<IActionResult> AcceptFollowRequest(string id)
{ {
@ -338,8 +324,6 @@ public class AccountController(
[HttpPost("/api/v1/follow_requests/{id}/reject")] [HttpPost("/api/v1/follow_requests/{id}/reject")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> RejectFollowRequest(string id) public async Task<IActionResult> RejectFollowRequest(string id)
{ {

View file

@ -23,8 +23,6 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[EnableCors("mastodon")] [EnableCors("mastodon")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public class ListController(DatabaseContext db, UserRenderer userRenderer) : ControllerBase public class ListController(DatabaseContext db, UserRenderer userRenderer) : ControllerBase
{ {
[HttpGet] [HttpGet]

View file

@ -17,8 +17,6 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public class MediaController(DriveService driveSvc) : ControllerBase public class MediaController(DriveService driveSvc) : ControllerBase
{ {
[HttpPost("/api/v1/media")] [HttpPost("/api/v1/media")]

View file

@ -85,8 +85,6 @@ public class StatusController(
[HttpPost("{id}/favourite")] [HttpPost("{id}/favourite")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> LikeNote(string id) public async Task<IActionResult> LikeNote(string id)
{ {
@ -104,8 +102,6 @@ public class StatusController(
[HttpPost("{id}/unfavourite")] [HttpPost("{id}/unfavourite")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UnlikeNote(string id) public async Task<IActionResult> UnlikeNote(string id)
{ {
@ -123,8 +119,6 @@ public class StatusController(
[HttpPost("{id}/reblog")] [HttpPost("{id}/reblog")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> Renote(string id, [FromHybrid] string? visibility) public async Task<IActionResult> Renote(string id, [FromHybrid] string? visibility)
{ {
@ -153,8 +147,6 @@ public class StatusController(
[HttpPost("{id}/unreblog")] [HttpPost("{id}/unreblog")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UndoRenote(string id) public async Task<IActionResult> UndoRenote(string id)
{ {

View file

@ -23,8 +23,6 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[EnableCors("mastodon")] [EnableCors("mastodon")]
[Produces(MediaTypeNames.Application.Json)] [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 public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, IDistributedCache cache) : ControllerBase
{ {
[Authorize("read:statuses")] [Authorize("read:statuses")]

View file

@ -41,8 +41,6 @@ public class NoteController(DatabaseContext db, NoteService noteSvc, NoteRendere
[Authorize] [Authorize]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
public async Task<IActionResult> CreateNote(NoteCreateRequest request) public async Task<IActionResult> CreateNote(NoteCreateRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();

View file

@ -23,8 +23,6 @@ public class TimelineController(DatabaseContext db, IDistributedCache cache, Not
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetHomeTimeline(PaginationQuery pq) public async Task<IActionResult> GetHomeTimeline(PaginationQuery pq)
{ {

View file

@ -147,14 +147,6 @@ public static class ServiceExtensions
Type = SecuritySchemeType.Http, Type = SecuritySchemeType.Http,
Scheme = "bearer" Scheme = "bearer"
}); });
options.AddSecurityDefinition("admin",
new OpenApiSecurityScheme
{
Name = "Authorization token",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "bearer"
});
options.AddSecurityDefinition("mastodon", options.AddSecurityDefinition("mastodon",
new OpenApiSecurityScheme new OpenApiSecurityScheme
{ {

View file

@ -3,6 +3,7 @@ using System.Reflection;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; 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.Any;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
@ -45,19 +46,25 @@ public static class SwaggerGenOptionsExtensions
if (context.MethodInfo.DeclaringType is null) if (context.MethodInfo.DeclaringType is null)
return; return;
//TODO: separate admin & user authorize attributes var authenticateAttribute = context.MethodInfo.GetCustomAttributes(true)
var hasAuthenticate = context.MethodInfo.DeclaringType.GetCustomAttributes(true) .OfType<AuthenticateAttribute>()
.OfType<AuthenticateAttribute>() .FirstOrDefault() ??
.Any() || context.MethodInfo.DeclaringType.GetCustomAttributes(true)
context.MethodInfo.GetCustomAttributes(true) .OfType<AuthenticateAttribute>()
.OfType<AuthenticateAttribute>() .FirstOrDefault();
.Any();
if (authenticateAttribute == null) return;
var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true) var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<MastodonApiControllerAttribute>() .OfType<MastodonApiControllerAttribute>()
.Any(); .Any();
if (!hasAuthenticate) return; var authorizeAttribute = context.MethodInfo.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>()
.FirstOrDefault() ??
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>()
.FirstOrDefault();
var schema = new OpenApiSecurityScheme var schema = new OpenApiSecurityScheme
{ {
@ -68,6 +75,72 @@ public static class SwaggerGenOptionsExtensions
}; };
operation.Security = new List<OpenApiSecurityRequirement> { new() { [schema] = Array.Empty<string>() } }; operation.Security = new List<OpenApiSecurityRequirement> { new() { [schema] = Array.Empty<string>() } };
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<string, OpenApiMediaType>
{
{ "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<string, OpenApiMediaType>
{
{ "application/json", new OpenApiMediaType { Example = example403 } }
}
};
operation.Responses.Add("403", res403);
} }
} }

View file

@ -31,7 +31,7 @@ public class AuthorizationMiddleware : IMiddleware
{ {
var session = ctx.GetSession(); var session = ctx.GetSession();
if (session is not { Active: true }) 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) if (attribute.AdminRole && !session.User.IsAdmin)
throw GracefulException.Forbidden("This action is outside the authorized scopes"); throw GracefulException.Forbidden("This action is outside the authorized scopes");
if (attribute.ModeratorRole && session.User is { IsAdmin: false, IsModerator: false }) if (attribute.ModeratorRole && session.User is { IsAdmin: false, IsModerator: false })