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
|
) : 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 =>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
Loading…
Reference in a new issue