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
{
[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 =>

View file

@ -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,

View file

@ -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);

View file

@ -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;

View file

@ -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; }
}

View file

@ -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(

View file

@ -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 =