feat(backend): allow suspended users to access some endpoints, add flag scopes

This commit is contained in:
sam 2024-12-11 16:54:06 +01:00
parent 7f8e72e857
commit 5cb3faa92b
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
7 changed files with 57 additions and 25 deletions

View file

@ -34,7 +34,8 @@ public class FlagsController(
) : ApiControllerBase ) : ApiControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize("identify")] [Limit(UsableBySuspendedUsers = true)]
[Authorize("user.read_flags")]
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default) public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
{ {
@ -50,7 +51,7 @@ public class FlagsController(
public const int MaxFlagCount = 500; public const int MaxFlagCount = 500;
[HttpPost] [HttpPost]
[Authorize("user.update")] [Authorize("user.update_flags")]
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)] [ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req) public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
{ {
@ -79,7 +80,7 @@ public class FlagsController(
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authorize("user.update")] [Authorize("user.create_flags")]
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req) public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
{ {
@ -104,7 +105,7 @@ public class FlagsController(
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize("user.update")] [Authorize("user.update_flags")]
public async Task<IActionResult> DeleteFlagAsync(Snowflake id) public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
{ {
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>

View file

@ -44,6 +44,7 @@ public class MembersController(
[HttpGet] [HttpGet]
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
{ {
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
@ -52,6 +53,7 @@ public class MembersController(
[HttpGet("{memberRef}")] [HttpGet("{memberRef}")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetMemberAsync( public async Task<IActionResult> GetMemberAsync(
string userRef, string userRef,
string memberRef, string memberRef,

View file

@ -42,6 +42,7 @@ public class UsersController(
[HttpGet("{userRef}")] [HttpGet("{userRef}")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{ {
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);

View file

@ -31,6 +31,7 @@ public static class DatabaseQueryExtensions
{ {
if (userRef == "@me") if (userRef == "@me")
{ {
// Not filtering deleted users, as a suspended user should still be able to look at their own profile.
return token != null return token != null
? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
: throw new ApiError.Unauthorized( : throw new ApiError.Unauthorized(
@ -43,14 +44,14 @@ public static class DatabaseQueryExtensions
if (Snowflake.TryParse(userRef, out Snowflake? snowflake)) if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
{ {
user = await context 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); .FirstOrDefaultAsync(u => u.Id == snowflake, ct);
if (user != null) if (user != null)
return user; return user;
} }
user = await context 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); .FirstOrDefaultAsync(u => u.Username == userRef, ct);
if (user != null) if (user != null)
return user; return user;
@ -98,13 +99,14 @@ public static class DatabaseQueryExtensions
) )
{ {
User user = await context.ResolveUserAsync(userRef, token, ct); 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( public static async Task<Member> ResolveMemberAsync(
this DatabaseContext context, this DatabaseContext context,
Snowflake userId, Snowflake userId,
string memberRef, string memberRef,
Token? token = null,
CancellationToken ct = default CancellationToken ct = default
) )
{ {
@ -114,7 +116,8 @@ public static class DatabaseQueryExtensions
member = await context member = await context
.Members.Include(m => m.User) .Members.Include(m => m.User)
.Include(m => m.ProfileFlags) .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); .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
if (member != null) if (member != null)
return member; return member;
@ -123,7 +126,8 @@ public static class DatabaseQueryExtensions
member = await context member = await context
.Members.Include(m => m.User) .Members.Include(m => m.User)
.Include(m => m.ProfileFlags) .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); .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
if (member != null) if (member != null)
return member; return member;

View file

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace Foxnouns.Backend.Middleware; namespace Foxnouns.Backend.Middleware;
@ -22,9 +23,11 @@ public class AuthorizationMiddleware : IMiddleware
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
Endpoint? endpoint = ctx.GetEndpoint(); 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); await next(ctx);
return; return;
@ -39,24 +42,35 @@ public class AuthorizationMiddleware : IMiddleware
); );
} }
// Users who got suspended by a moderator can still access *some* endpoints.
if ( if (
attribute.Scopes.Length > 0 token.User.Deleted
&& attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() && (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( throw new ApiError.Forbidden(
"This endpoint requires ungranted scopes.", "This endpoint requires ungranted scopes.",
attribute.Scopes.Except(token.Scopes.ExpandScopes()), authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()),
ErrorCode.MissingScopes 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."); throw new ApiError.Forbidden("This endpoint can only be used by admins.");
}
if ( if (
attribute.RequireModerator limitAttribute?.RequireModerator == true
&& token.User.Role != UserRole.Admin && token.User.Role is not (UserRole.Admin or UserRole.Moderator)
&& token.User.Role != UserRole.Moderator
) )
{ {
throw new ApiError.Forbidden("This endpoint can only be used by moderators."); 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)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute(params string[] scopes) : Attribute public class AuthorizeAttribute(params string[] scopes) : Attribute
{ {
public readonly bool RequireAdmin = scopes.Contains(":admin"); public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
public readonly bool RequireModerator = scopes.Contains(":moderator"); }
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).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; }
} }

View file

@ -66,9 +66,11 @@ builder
}) })
.ConfigureApiBehaviorOptions(options => .ConfigureApiBehaviorOptions(options =>
{ {
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult( // the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine)
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
); new BadRequestObjectResult(
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
);
}); });
builder.Services.AddOpenApi( builder.Services.AddOpenApi(

View file

@ -35,6 +35,9 @@ public static class AuthUtils
"user.read_hidden", "user.read_hidden",
"user.read_privileged", "user.read_privileged",
"user.update", "user.update",
"user.read_flags",
"user.create_flags",
"user.update_flags",
]; ];
public static readonly string[] MemberScopes = public static readonly string[] MemberScopes =