feat(backend): allow suspended users to access some endpoints, add flag scopes
This commit is contained in:
		
							parent
							
								
									7f8e72e857
								
							
						
					
					
						commit
						5cb3faa92b
					
				
					 7 changed files with 57 additions and 25 deletions
				
			
		|  | @ -34,7 +34,8 @@ public class FlagsController( | |||
| ) : ApiControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     [Authorize("identify")] | ||||
|     [Limit(UsableBySuspendedUsers = true)] | ||||
|     [Authorize("user.read_flags")] | ||||
|     [ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)] | ||||
|     public async Task<IActionResult> 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<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)] | ||||
|     public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req) | ||||
|     { | ||||
|  | @ -79,7 +80,7 @@ public class FlagsController( | |||
|     } | ||||
| 
 | ||||
|     [HttpPatch("{id}")] | ||||
|     [Authorize("user.update")] | ||||
|     [Authorize("user.create_flags")] | ||||
|     [ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)] | ||||
|     public async Task<IActionResult> 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<IActionResult> DeleteFlagAsync(Snowflake id) | ||||
|     { | ||||
|         PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => | ||||
|  |  | |||
|  | @ -44,6 +44,7 @@ public class MembersController( | |||
| 
 | ||||
|     [HttpGet] | ||||
|     [ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)] | ||||
|     [Limit(UsableBySuspendedUsers = true)] | ||||
|     public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default) | ||||
|     { | ||||
|         User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); | ||||
|  | @ -52,6 +53,7 @@ public class MembersController( | |||
| 
 | ||||
|     [HttpGet("{memberRef}")] | ||||
|     [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)] | ||||
|     [Limit(UsableBySuspendedUsers = true)] | ||||
|     public async Task<IActionResult> GetMemberAsync( | ||||
|         string userRef, | ||||
|         string memberRef, | ||||
|  |  | |||
|  | @ -42,6 +42,7 @@ public class UsersController( | |||
| 
 | ||||
|     [HttpGet("{userRef}")] | ||||
|     [ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)] | ||||
|     [Limit(UsableBySuspendedUsers = true)] | ||||
|     public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) | ||||
|     { | ||||
|         User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); | ||||
|  |  | |||
|  | @ -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<Member> 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; | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ | |||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| 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? authorizeAttribute = | ||||
|             endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); | ||||
|         LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>(); | ||||
| 
 | ||||
|         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; } | ||||
| } | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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 = | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue