From 5cb3faa92ba7d67690392cd783762baab4c51911 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 11 Dec 2024 16:54:06 +0100 Subject: [PATCH] feat(backend): allow suspended users to access some endpoints, add flag scopes --- .../Controllers/FlagsController.cs | 9 ++-- .../Controllers/MembersController.cs | 2 + .../Controllers/UsersController.cs | 1 + .../Database/DatabaseQueryExtensions.cs | 14 +++--- .../Middleware/AuthorizationMiddleware.cs | 45 +++++++++++++------ Foxnouns.Backend/Program.cs | 8 ++-- Foxnouns.Backend/Utils/AuthUtils.cs | 3 ++ 7 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index 0c35afd..c68fb96 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -34,7 +34,8 @@ public class FlagsController( ) : ApiControllerBase { [HttpGet] - [Authorize("identify")] + [Limit(UsableBySuspendedUsers = true)] + [Authorize("user.read_flags")] [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] public async Task GetFlagsAsync(CancellationToken ct = default) { @@ -50,7 +51,7 @@ public class FlagsController( public const int MaxFlagCount = 500; [HttpPost] - [Authorize("user.update")] + [Authorize("user.update_flags")] [ProducesResponseType(statusCode: StatusCodes.Status202Accepted)] public async Task CreateFlagAsync([FromBody] CreateFlagRequest req) { @@ -79,7 +80,7 @@ public class FlagsController( } [HttpPatch("{id}")] - [Authorize("user.update")] + [Authorize("user.create_flags")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req) { @@ -104,7 +105,7 @@ public class FlagsController( } [HttpDelete("{id}")] - [Authorize("user.update")] + [Authorize("user.update_flags")] public async Task DeleteFlagAsync(Snowflake id) { PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 534c51f..9b94b30 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -44,6 +44,7 @@ public class MembersController( [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] + [Limit(UsableBySuspendedUsers = true)] public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); @@ -52,6 +53,7 @@ public class MembersController( [HttpGet("{memberRef}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Limit(UsableBySuspendedUsers = true)] public async Task GetMemberAsync( string userRef, string memberRef, diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 4a3be72..d567bdb 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -42,6 +42,7 @@ public class UsersController( [HttpGet("{userRef}")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [Limit(UsableBySuspendedUsers = true)] public async Task GetUserAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index 790b9df..d804dfe 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -31,6 +31,7 @@ public static class DatabaseQueryExtensions { if (userRef == "@me") { + // Not filtering deleted users, as a suspended user should still be able to look at their own profile. return token != null ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) : throw new ApiError.Unauthorized( @@ -43,14 +44,14 @@ public static class DatabaseQueryExtensions if (Snowflake.TryParse(userRef, out Snowflake? snowflake)) { user = await context - .Users.Where(u => !u.Deleted) + .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id)) .FirstOrDefaultAsync(u => u.Id == snowflake, ct); if (user != null) return user; } user = await context - .Users.Where(u => !u.Deleted) + .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id)) .FirstOrDefaultAsync(u => u.Username == userRef, ct); if (user != null) return user; @@ -98,13 +99,14 @@ public static class DatabaseQueryExtensions ) { User user = await context.ResolveUserAsync(userRef, token, ct); - return await context.ResolveMemberAsync(user.Id, memberRef, ct); + return await context.ResolveMemberAsync(user.Id, memberRef, token, ct); } public static async Task ResolveMemberAsync( this DatabaseContext context, Snowflake userId, string memberRef, + Token? token = null, CancellationToken ct = default ) { @@ -114,7 +116,8 @@ public static class DatabaseQueryExtensions member = await context .Members.Include(m => m.User) .Include(m => m.ProfileFlags) - .Where(m => !m.User.Deleted) + // Return members if their user isn't deleted or the user querying it is the member's owner + .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId)) .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); if (member != null) return member; @@ -123,7 +126,8 @@ public static class DatabaseQueryExtensions member = await context .Members.Include(m => m.User) .Include(m => m.ProfileFlags) - .Where(m => !m.User.Deleted) + // Return members if their user isn't deleted or the user querying it is the member's owner + .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId)) .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); if (member != null) return member; diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs index 1132dc1..908598a 100644 --- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -14,6 +14,7 @@ // along with this program. If not, see . using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; +using Microsoft.AspNetCore.Mvc.ViewFeatures; namespace Foxnouns.Backend.Middleware; @@ -22,9 +23,11 @@ public class AuthorizationMiddleware : IMiddleware public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { Endpoint? endpoint = ctx.GetEndpoint(); - AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata(); + AuthorizeAttribute? authorizeAttribute = + endpoint?.Metadata.GetMetadata(); + LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata(); - if (attribute == null) + if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) { await next(ctx); return; @@ -39,24 +42,35 @@ public class AuthorizationMiddleware : IMiddleware ); } + // Users who got suspended by a moderator can still access *some* endpoints. if ( - attribute.Scopes.Length > 0 - && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() + token.User.Deleted + && (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null) + ) + { + throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); + } + + if ( + authorizeAttribute.Scopes.Length > 0 + && authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() ) { throw new ApiError.Forbidden( "This endpoint requires ungranted scopes.", - attribute.Scopes.Except(token.Scopes.ExpandScopes()), + authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()), ErrorCode.MissingScopes ); } - if (attribute.RequireAdmin && token.User.Role != UserRole.Admin) + if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin) + { throw new ApiError.Forbidden("This endpoint can only be used by admins."); + } + if ( - attribute.RequireModerator - && token.User.Role != UserRole.Admin - && token.User.Role != UserRole.Moderator + limitAttribute?.RequireModerator == true + && token.User.Role is not (UserRole.Admin or UserRole.Moderator) ) { throw new ApiError.Forbidden("This endpoint can only be used by moderators."); @@ -69,8 +83,13 @@ public class AuthorizationMiddleware : IMiddleware [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class AuthorizeAttribute(params string[] scopes) : Attribute { - public readonly bool RequireAdmin = scopes.Contains(":admin"); - public readonly bool RequireModerator = scopes.Contains(":moderator"); - - public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray(); + public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray(); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class LimitAttribute : Attribute +{ + public bool UsableBySuspendedUsers { get; init; } + public bool RequireAdmin { get; init; } + public bool RequireModerator { get; init; } } diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 3597ae7..66e57a6 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -66,9 +66,11 @@ builder }) .ConfigureApiBehaviorOptions(options => { - options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult( - new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() - ); + // the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine) + options.InvalidModelStateResponseFactory = (ActionContext actionContext) => + new BadRequestObjectResult( + new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() + ); }); builder.Services.AddOpenApi( diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index bcc2700..491694a 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -35,6 +35,9 @@ public static class AuthUtils "user.read_hidden", "user.read_privileged", "user.update", + "user.read_flags", + "user.create_flags", + "user.update_flags", ]; public static readonly string[] MemberScopes =