Skip to content

Commit

Permalink
Multiple roles support (#154)
Browse files Browse the repository at this point in the history
* Add initial WIP POC for consideration

* Add POC migrations

* Don't include user role

* Staff, admins, and system is not support

* Update migration

* Fix stuffz

* Add responses to auth failures

* Add response to policy failure

* Fix admin user view

---------

Co-authored-by: LucHeart <[email protected]>
  • Loading branch information
hhvrc and LucHeart authored Feb 5, 2025
1 parent 9a41128 commit 31e1ec1
Show file tree
Hide file tree
Showing 34 changed files with 1,446 additions and 113 deletions.
2 changes: 1 addition & 1 deletion API/Controller/Account/Authenticated/ChangeUsername.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public sealed partial class AuthenticatedAccountController
public async Task<IActionResult> ChangeUsername(ChangeUsernameRequest data)
{
var result = await _accountService.ChangeUsername(CurrentUser.Id, data.Username,
CurrentUser.Rank.IsAllowed(RankType.Staff));
CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));

return result.Match<IActionResult>(
success => Ok(),
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Admin/DeleteUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public async Task<IActionResult> DeleteUser([FromRoute] Guid userId)
return Problem(AdminError.UserNotFound);
}

if (user.Rank >= RankType.Admin)
if (user.Roles.Any(r => r is RoleType.Admin or RoleType.System))
{
return Problem(AdminError.CannotDeletePrivledgedAccount);
}
Expand Down
3 changes: 2 additions & 1 deletion API/Controller/Admin/_ApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
using Microsoft.AspNetCore.Mvc;
using OpenShock.Common.Authentication;
using OpenShock.Common.Authentication.ControllerBase;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
using Redis.OM.Contracts;

namespace OpenShock.API.Controller.Admin;

[ApiController]
[Route("/{version:apiVersion}/admin")]
[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie, Policy = OpenShockAuthPolicies.AdminAccess)]
[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie, Roles = "Admin")]
public sealed partial class AdminController : AuthenticatedSessionControllerBase
{
private readonly OpenShockContext _db;
Expand Down
15 changes: 13 additions & 2 deletions API/Controller/Devices/DeviceOtaController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,28 @@
using Microsoft.EntityFrameworkCore;
using OpenShock.Common.Authentication;
using OpenShock.Common.Authentication.Attributes;
using OpenShock.Common.Authentication.ControllerBase;
using OpenShock.Common.Errors;
using OpenShock.Common.Models;
using OpenShock.Common.Models.Services.Ota;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.Ota;

namespace OpenShock.API.Controller.Devices;

public sealed partial class DevicesController
[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie)]
public sealed class DevicesOtaController : AuthenticatedSessionControllerBase
{
private readonly OpenShockContext _db;
private readonly ILogger<DevicesController> _logger;

public DevicesOtaController(OpenShockContext db, ILogger<DevicesController> logger)
{
_db = db;
_logger = logger;
}

/// <summary>
/// Gets the OTA update history for a device
/// </summary>
Expand All @@ -25,7 +37,6 @@ public sealed partial class DevicesController
/// <response code="404">Could not find device or you do not have access to it</response>
[HttpGet("{deviceId}/ota")]
[MapToApiVersion("1")]
[Authorize(Policy = OpenShockAuthPolicies.UserAccess)]
[ProducesResponseType<BaseResponse<IReadOnlyCollection<OtaItem>>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound
public async Task<IActionResult> GetOtaUpdateHistory([FromRoute] Guid deviceId, [FromServices] IOtaService otaService)
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Devices/DevicesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public async Task<IActionResult> RegenerateDeviceToken([FromRoute] Guid deviceId
[MapToApiVersion("1")]
public async Task<IActionResult> RemoveDevice([FromRoute] Guid deviceId, [FromServices] IDeviceUpdateService updateService)
{
var affected = await _db.Devices.Where(x => x.Id == deviceId).WhereIsUserOrAdmin(x => x.OwnerNavigation, CurrentUser).ExecuteDeleteAsync();
var affected = await _db.Devices.Where(x => x.Id == deviceId).WhereIsUserOrPrivileged(x => x.OwnerNavigation, CurrentUser).ExecuteDeleteAsync();
if (affected <= 0) return Problem(DeviceError.DeviceNotFound);

await updateService.UpdateDeviceForAllShared(CurrentUser.Id, deviceId, DeviceUpdateType.Deleted);
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Sessions/DeleteSessions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public async Task<IActionResult> DeleteSession(Guid sessionId)
var loginSession = await _sessionService.GetSessionByPulbicId(sessionId);

// If the session was not found, or the user does not have the privledges to access it, return NotFound
if (loginSession == null || !CurrentUser.IsUserOrRank(loginSession.UserId, RankType.Admin))
if (loginSession == null || !CurrentUser.IsUserOrRole(loginSession.UserId, RoleType.Admin))
{
return Problem(SessionError.SessionNotFound);
}
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Shares/DeleteShareCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task<IActionResult> DeleteShareCode([FromRoute] Guid shareCodeId)
{
var affected = await _db.ShockerShareCodes
.Where(x => x.Id == shareCodeId)
.WhereIsUserOrAdmin(x => x.Shocker.DeviceNavigation.OwnerNavigation, CurrentUser)
.WhereIsUserOrPrivileged(x => x.Shocker.DeviceNavigation.OwnerNavigation, CurrentUser)
.ExecuteDeleteAsync();
if (affected <= 0)
{
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Shares/Links/DeleteShareLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<IActionResult> DeleteShareLink([FromRoute] Guid shareLinkId)
{
var result = await _db.ShockerSharesLinks
.Where(x => x.Id == shareLinkId)
.WhereIsUserOrAdmin(x => x.Owner, CurrentUser)
.WhereIsUserOrPrivileged(x => x.Owner, CurrentUser)
.ExecuteDeleteAsync();

return result > 0
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Shares/Links/DeleteShockerShareLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task<IActionResult> RemoveShocker([FromRoute] Guid shareLinkId, [Fr
{
var exists = await _db.ShockerSharesLinks
.Where(x => x.Id == shareLinkId)
.WhereIsUserOrAdmin(x => x.Owner, CurrentUser)
.WhereIsUserOrPrivileged(x => x.Owner, CurrentUser)
.AnyAsync();
if (!exists)
{
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Shockers/DeleteShockerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task<IActionResult> RemoveShocker(
{
var affected = await _db.Shockers
.Where(x => x.Id == shockerId)
.WhereIsUserOrAdmin(x => x.DeviceNavigation.OwnerNavigation, CurrentUser)
.WhereIsUserOrPrivileged(x => x.DeviceNavigation.OwnerNavigation, CurrentUser)
.FirstOrDefaultAsync();

if (affected == null)
Expand Down
17 changes: 7 additions & 10 deletions API/Controller/Tokens/TokenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@ public sealed partial class TokensController
/// </summary>
/// <response code="200">All tokens for the current user</response>
[HttpGet]
[Authorize(Policy = OpenShockAuthPolicies.UserAccess)]
[ProducesResponseType<TokenResponse[]>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<TokenResponse[]> ListTokens()
[ProducesResponseType<IEnumerable<TokenResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<IEnumerable<TokenResponse>> ListTokens()
{
return await _db.ApiTokens
var apiTokens = await _db.ApiTokens
.Where(x => x.UserId == CurrentUser.Id && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow))
.OrderBy(x => x.CreatedOn)
.Select(x => new TokenResponse
Expand All @@ -40,7 +39,9 @@ public async Task<TokenResponse[]> ListTokens()
Permissions = x.Permissions,
Name = x.Name,
Id = x.Id
}).ToArrayAsync();
}).ToListAsync();

return apiTokens;
}

/// <summary>
Expand All @@ -50,7 +51,6 @@ public async Task<TokenResponse[]> ListTokens()
/// <response code="200">The token</response>
/// <response code="404">The token does not exist or you do not have access to it.</response>
[HttpGet("{tokenId}")]
[Authorize(Policy = OpenShockAuthPolicies.UserAccess)]
[ProducesResponseType<TokenResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> GetTokenById([FromRoute] Guid tokenId)
Expand Down Expand Up @@ -79,14 +79,13 @@ public async Task<IActionResult> GetTokenById([FromRoute] Guid tokenId)
/// <response code="200">Successfully deleted token</response>
/// <response code="404">The token does not exist or you do not have access to it.</response>
[HttpDelete("{tokenId}")]
[Authorize(Policy = OpenShockAuthPolicies.UserAccess)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> DeleteToken([FromRoute] Guid tokenId)
{
var apiToken = await _db.ApiTokens
.Where(x => x.Id == tokenId)
.WhereIsUserOrAdmin(x => x.User, CurrentUser)
.WhereIsUserOrPrivileged(x => x.User, CurrentUser)
.ExecuteDeleteAsync();

if (apiToken <= 0)
Expand All @@ -103,7 +102,6 @@ public async Task<IActionResult> DeleteToken([FromRoute] Guid tokenId)
/// <param name="body"></param>
/// <response code="200">The created token</response>
[HttpPost]
[Authorize(Policy = OpenShockAuthPolicies.UserAccess)]
[ProducesResponseType<TokenCreatedResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<TokenCreatedResponse> CreateToken([FromBody] CreateTokenRequest body)
{
Expand Down Expand Up @@ -137,7 +135,6 @@ public async Task<TokenCreatedResponse> CreateToken([FromBody] CreateTokenReques
/// <response code="200">The edited token</response>
/// <response code="404">The token does not exist or you do not have access to it.</response>
[HttpPatch("{tokenId}")]
[Authorize(Policy = OpenShockAuthPolicies.UserAccess)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body)
Expand Down
7 changes: 5 additions & 2 deletions API/Controller/Tokens/TokenSelfController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
using OpenShock.API.Models.Response;
using OpenShock.Common.Authentication;
using OpenShock.Common.Authentication.Attributes;
using OpenShock.Common.Authentication.ControllerBase;
using OpenShock.Common.Authentication.Services;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Problems;

namespace OpenShock.API.Controller.Tokens;

public sealed partial class TokensController
[ApiController]
[Route("/{version:apiVersion}/tokens")]
[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.ApiToken)]
public sealed partial class TokensSelfController : AuthenticatedSessionControllerBase
{
/// <summary>
/// Gets information about the current token used to access this endpoint
Expand All @@ -19,7 +23,6 @@ public sealed partial class TokensController
/// <returns></returns>
/// <exception cref="Exception"></exception>
[HttpGet("self")]
[Authorize(Policy = OpenShockAuthPolicies.TokenSessionOnly)]
[ProducesResponseType<TokenResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public TokenResponse GetSelfToken([FromServices] IUserReferenceService userReferenceService)
{
Expand Down
7 changes: 2 additions & 5 deletions API/Controller/Tokens/_ApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,20 @@
using OpenShock.Common.Authentication;
using OpenShock.Common.Authentication.ControllerBase;
using OpenShock.Common.OpenShockDb;
using Redis.OM.Contracts;

namespace OpenShock.API.Controller.Tokens;

[ApiController]
[Route("/{version:apiVersion}/tokens")]
[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionApiTokenCombo)]
[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie)]
public sealed partial class TokensController : AuthenticatedSessionControllerBase
{
private readonly OpenShockContext _db;
private readonly IRedisConnectionProvider _redis;
private readonly ILogger<TokensController> _logger;

public TokensController(OpenShockContext db, IRedisConnectionProvider redis, ILogger<TokensController> logger)
public TokensController(OpenShockContext db, ILogger<TokensController> logger)
{
_db = db;
_redis = redis;
_logger = logger;
}
}
4 changes: 2 additions & 2 deletions API/Controller/Users/GetSelf.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public sealed partial class UsersController
Name = CurrentUser.Name,
Email = CurrentUser.Email,
Image = CurrentUser.GetImageUrl(),
Rank = CurrentUser.Rank
Roles = CurrentUser.Roles
}
};
public sealed class SelfResponse
Expand All @@ -30,6 +30,6 @@ public sealed class SelfResponse
public required string Name { get; set; }
public required string Email { get; set; }
public required Uri Image { get; set; }
public required RankType Rank { get; set; }
public required List<RoleType> Roles { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication;
using System.Net.Mime;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
Expand All @@ -10,6 +11,7 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using OpenShock.Common.Problems;

namespace OpenShock.Common.Authentication.AuthenticationHandlers;

Expand All @@ -20,6 +22,7 @@ public sealed class ApiTokenAuthentication : AuthenticationHandler<Authenticatio
private readonly IBatchUpdateService _batchUpdateService;
private readonly OpenShockContext _db;
private readonly JsonSerializerOptions _serializerOptions;
private OpenShockProblem? _authResultError = null;

public ApiTokenAuthentication(
IOptionsMonitor<AuthenticationSchemeOptions> options,
Expand All @@ -42,14 +45,14 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Context.TryGetApiTokenFromHeader(out var token))
{
return AuthenticateResult.Fail(AuthResultError.HeaderMissingOrInvalid.Title!);
return Fail(AuthResultError.HeaderMissingOrInvalid);
}

string tokenHash = HashingUtils.HashSha256(token);
var tokenHash = HashingUtils.HashSha256(token);

var tokenDto = await _db.ApiTokens.Include(x => x.User).FirstOrDefaultAsync(x => x.TokenHash == tokenHash &&
(x.ValidUntil == null || x.ValidUntil >= DateTime.UtcNow));
if (tokenDto == null) return AuthenticateResult.Fail(AuthResultError.TokenInvalid.Title!);
if (tokenDto == null) return Fail(AuthResultError.TokenInvalid);

_batchUpdateService.UpdateTokenLastUsed(tokenDto.Id);
_authService.CurrentClient = tokenDto.User;
Expand All @@ -74,4 +77,20 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()

return AuthenticateResult.Success(ticket);
}

private AuthenticateResult Fail(OpenShockProblem reason)
{
_authResultError = reason;
return AuthenticateResult.Fail(reason.Type!);
}

/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (Context.Response.HasStarted) return Task.CompletedTask;
_authResultError ??= AuthResultError.UnknownError;
Response.StatusCode = _authResultError.Status!.Value;
_authResultError.AddContext(Context);
return Context.Response.WriteAsJsonAsync(_authResultError, _serializerOptions, contentType: MediaTypeNames.Application.ProblemJson);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ private AuthenticateResult Fail(OpenShockProblem reason)
/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (Context.Response.HasStarted) return Task.CompletedTask;
_authResultError ??= AuthResultError.UnknownError;
Response.StatusCode = _authResultError.Status!.Value;
_authResultError.AddContext(Context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public sealed class UserSessionAuthentication : AuthenticationHandler<Authentica
private readonly OpenShockContext _db;
private readonly ISessionService _sessionService;
private readonly JsonSerializerOptions _serializerOptions;
private OpenShockProblem? _authResultError = null;

public UserSessionAuthentication(
IOptionsMonitor<AuthenticationSchemeOptions> options,
Expand All @@ -49,11 +50,11 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Context.TryGetUserSession(out var sessionKey))
{
return AuthenticateResult.Fail(AuthResultError.CookieMissingOrInvalid.Type!);
return Fail(AuthResultError.CookieMissingOrInvalid);
}

var session = await _sessionService.GetSessionById(sessionKey);
if (session == null) return AuthenticateResult.Fail(AuthResultError.SessionInvalid.Type!);
if (session == null) return Fail(AuthResultError.SessionInvalid);

if (session.Expires!.Value < DateTime.UtcNow.Subtract(Duration.LoginSessionExpansionAfter))
{
Expand All @@ -76,9 +77,10 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
List<Claim> claims = [
new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemas.UserSessionCookie),
new(ClaimTypes.NameIdentifier, retrievedUser.Id.ToString()),
new(ClaimTypes.Role, retrievedUser.Rank.ToString())
];

claims.AddRange(retrievedUser.Roles.Select(r => new Claim(ClaimTypes.Role, r.ToString())));

var ident = new ClaimsIdentity(claims, nameof(UserSessionAuthentication));

Context.User = new ClaimsPrincipal(ident);
Expand All @@ -87,4 +89,20 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()

return AuthenticateResult.Success(ticket);
}

private AuthenticateResult Fail(OpenShockProblem reason)
{
_authResultError = reason;
return AuthenticateResult.Fail(reason.Type!);
}

/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (Context.Response.HasStarted) return Task.CompletedTask;
_authResultError ??= AuthResultError.UnknownError;
Response.StatusCode = _authResultError.Status!.Value;
_authResultError.AddContext(Context);
return Context.Response.WriteAsJsonAsync(_authResultError, _serializerOptions, contentType: MediaTypeNames.Application.ProblemJson);
}
}
Loading

0 comments on commit 31e1ec1

Please sign in to comment.