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…
Reference in a new issue