Finished permission management

This commit is contained in:
2024-07-13 18:26:46 +02:00
parent d91ed3ad3a
commit f1266783b3
14 changed files with 132 additions and 41 deletions

View File

@@ -1,9 +1,16 @@
using HopFrame.Api.Controller;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Mvc;
namespace DatabaseTest.Controllers;
[ApiController]
public class TestController(DatabaseContext context) : SecurityController<DatabaseContext>(context) {
public class TestController(DatabaseContext context, ITokenContext userContext) : SecurityController<DatabaseContext>(context) {
[HttpGet("permissions"), Authorized]
public ActionResult<IList<string>> Permissions() {
return new ActionResult<IList<string>>(userContext.User.Permissions);
}
}

BIN
DatabaseTest/test.db Normal file

Binary file not shown.

View File

@@ -20,15 +20,15 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
private const string RefreshTokenType = "HopFrame.Security.RefreshToken";
[HttpPut("login")]
public async Task<ILogicResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) {
public async Task<ActionResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) {
var user = await context.Users.SingleOrDefaultAsync(user => user.Email == login.Email);
if (user is null)
return LogicResult<SingleValueResult<string>>.NotFound("The provided email address was not found");
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.NotFound("The provided email address was not found"));
var hashedPassword = EncryptionManager.Hash(login.Password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
if (hashedPassword != user.Password)
return LogicResult<SingleValueResult<string>>.Forbidden("The provided password is not correct");
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Forbidden("The provided password is not correct"));
var refreshToken = new TokenEntry {
CreatedAt = DateTime.Now,
@@ -52,15 +52,16 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
await context.Tokens.AddRangeAsync(refreshToken, accessToken);
await context.SaveChangesAsync();
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Ok(accessToken.Token));
}
[HttpPost("register")]
public async Task<ILogicResult<SingleValueResult<string>>> Register([FromBody] UserRegister register) {
//TODO: Validate Password requirements
public async Task<ActionResult<SingleValueResult<string>>> Register([FromBody] UserRegister register) {
if (register.Password.Length < 8)
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Conflict("Password needs to be at least 8 characters long"));
if (await context.Users.AnyAsync(user => user.Username == register.Username || user.Email == register.Email))
return LogicResult<SingleValueResult<string>>.Conflict("Username or Email is already registered");
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Conflict("Username or Email is already registered"));
var user = new UserEntry {
CreatedAt = DateTime.Now,
@@ -72,6 +73,17 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
await context.Users.AddAsync(user);
var defaultGroups = await context.Groups
.Where(group => group.Default)
.Select(group => "group." + group.Name)
.ToListAsync();
await context.Permissions.AddRangeAsync(defaultGroups.Select(group => new PermissionEntry {
GrantedAt = DateTime.Now,
PermissionText = group,
UserId = user.Id
}));
var refreshToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
@@ -94,23 +106,23 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
await context.Tokens.AddRangeAsync(refreshToken, accessToken);
await context.SaveChangesAsync();
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Ok(accessToken.Token));
}
[HttpGet("authenticate")]
public async Task<ILogicResult<SingleValueResult<string>>> Authenticate() {
public async Task<ActionResult<SingleValueResult<string>>> Authenticate() {
var refreshToken = HttpContext.Request.Cookies[RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
return LogicResult<SingleValueResult<string>>.Conflict("Refresh token not provided");
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Conflict("Refresh token not provided"));
var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType);
if (token is null)
return LogicResult<SingleValueResult<string>>.NotFound("Refresh token not valid");
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.NotFound("Refresh token not valid"));
if (token.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now)
return LogicResult<SingleValueResult<string>>.Conflict("Refresh token is expired");
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Conflict("Refresh token is expired"));
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
@@ -122,16 +134,16 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
await context.Tokens.AddAsync(accessToken);
await context.SaveChangesAsync();
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
return this.FromLogicResult(LogicResult<SingleValueResult<string>>.Ok(accessToken.Token));
}
[HttpDelete("logout"), Authorized]
public async Task<ILogicResult> Logout() {
public async Task<ActionResult> Logout() {
var accessToken = HttpContext.User.GetAccessTokenId();
var refreshToken = HttpContext.Request.Cookies[RefreshTokenType];
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return LogicResult.Conflict("access or refresh token not provided");
return this.FromLogicResult(LogicResult.Conflict("access or refresh token not provided"));
var tokenEntries = await context.Tokens.Where(token =>
(token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) ||
@@ -139,7 +151,7 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
.ToArrayAsync();
if (tokenEntries.Length != 2)
return LogicResult.NotFound("One or more of the provided tokens was not found");
return this.FromLogicResult(LogicResult.NotFound("One or more of the provided tokens was not found"));
context.Tokens.Remove(tokenEntries[0]);
context.Tokens.Remove(tokenEntries[1]);
@@ -147,32 +159,34 @@ public class SecurityController<TDbContext>(TDbContext context) : ControllerBase
HttpContext.Response.Cookies.Delete(RefreshTokenType);
return LogicResult.Ok();
return this.FromLogicResult(LogicResult.Ok());
}
[HttpDelete("delete"), Authorized]
public async Task<ILogicResult> Delete([FromBody] UserLogin login) {
public async Task<ActionResult> Delete([FromBody] UserLogin login) {
var token = HttpContext.User.GetAccessTokenId();
var userId = (await context.Tokens.SingleOrDefaultAsync(t => t.Token == token && t.Type == TokenEntry.AccessTokenType))?.UserId;
if (string.IsNullOrEmpty(userId))
return LogicResult.NotFound("Access token does not match any user");
return this.FromLogicResult(LogicResult.NotFound("Access token does not match any user"));
var user = await context.Users.SingleAsync(user => user.Id == userId);
var password = EncryptionManager.Hash(login.Password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
if (user.Password != password)
return LogicResult.Forbidden("The provided password is not correct");
return this.FromLogicResult(LogicResult.Forbidden("The provided password is not correct"));
var tokens = await context.Tokens.Where(t => t.UserId == userId).ToArrayAsync();
var permissions = await context.Permissions.Where(perm => perm.UserId == userId).ToArrayAsync();
context.Tokens.RemoveRange(tokens);
context.Permissions.RemoveRange(permissions);
context.Users.Remove(user);
await context.SaveChangesAsync();
HttpContext.Response.Cookies.Delete(RefreshTokenType);
return LogicResult.Ok();
return this.FromLogicResult(LogicResult.Ok());
}
}

View File

@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Logic;
public class LogicResult : ILogicResult {

View File

@@ -12,11 +12,14 @@ public class HopDbContextBase : DbContext {
public virtual DbSet<UserEntry> Users { get; set; }
public virtual DbSet<PermissionEntry> Permissions { get; set; }
public virtual DbSet<TokenEntry> Tokens { get; set; }
public virtual DbSet<GroupEntry> Groups { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UserEntry>();
modelBuilder.Entity<PermissionEntry>();
modelBuilder.Entity<TokenEntry>();
modelBuilder.Entity<GroupEntry>();
}
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models.Entries;
public class GroupEntry {
[Key, Required, MaxLength(50)]
public string Name { get; set; }
[Required, DefaultValue(false)]
public bool Default { get; set; }
[MaxLength(500)]
public string Description { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
#pragma warning disable CS0618 // Type or member is obsolete
namespace HopFrame.Security.Authentication;
@@ -38,7 +39,7 @@ public class HopFrameAuthentication<TDbContext> : AuthenticationHandler<Authenti
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, accessToken),
new(HopFrameClaimTypes.UserId, tokenEntry.UserId.ToString())
new(HopFrameClaimTypes.UserId, tokenEntry.UserId)
};
var permissions = await _context.Permissions
@@ -46,6 +47,17 @@ public class HopFrameAuthentication<TDbContext> : AuthenticationHandler<Authenti
.Select(perm => perm.PermissionText)
.ToListAsync();
var groups = permissions
.Where(perm => perm.StartsWith("group."))
.ToList();
var groupPerms = await _context.Permissions
.Where(perm => groups.Contains(perm.UserId))
.Select(perm => perm.PermissionText)
.ToListAsync();
permissions.AddRange(groupPerms);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal();

View File

@@ -1,12 +1,17 @@
using HopFrame.Database;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Security.Authentication;
public static class HopFrameAuthenticationExtensions {
public static AuthenticationBuilder AddHopFrameAuthentication<TDbContext>(this IServiceCollection service) where TDbContext : HopDbContextBase {
service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
service.AddScoped<ITokenContext, TokenContextImplementor<TDbContext>>();
return service.AddAuthentication(HopFrameAuthentication<TDbContext>.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication<TDbContext>>(HopFrameAuthentication<TDbContext>.SchemeName, _ => {});
}

View File

@@ -1,3 +1,4 @@
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -19,6 +20,13 @@ public class AuthorizedFilter : IAuthorizationFilter {
return;
}
//TODO: Check Permissions
if (_permissions.Length == 0) return;
var permissions = context.HttpContext.User.GetPermissions();
if (!_permissions.Any(permission => PermissionValidator.IncludesPermission(permission, permissions))) {
context.Result = new UnauthorizedResult();
return;
}
}
}

View File

@@ -0,0 +1,30 @@
namespace HopFrame.Security.Authorization;
internal static class PermissionValidator {
/// <summary>
/// Checks for the user to have the specified permission<br/>
/// Permission system:<br/>
/// - "*" -> all rights<br/>
/// - "group.[name]" -> group member<br/>
/// - "[namespace].[name]" -> single permission<br/>
/// - "[namespace].*" -> all permissions in the namespace
/// </summary>
/// <param name="permission">The permission the user needs</param>
/// <param name="permissions">All the permissions the user has (includes group permissions)</param>
/// <returns></returns>
public static bool IncludesPermission(string permission, string[] permissions) {
if (permission == "*") return true;
if (permissions.Contains(permission)) return true;
foreach (var perm in permissions) {
if (!perm.EndsWith(".*")) continue;
var permissionGroup = perm.Replace(".*", "");
if (permission.StartsWith(permissionGroup)) return true;
}
return false;
}
}

View File

@@ -2,7 +2,7 @@ using HopFrame.Database.Models;
namespace HopFrame.Security.Claims;
public interface ITokenContextBase {
public interface ITokenContext {
bool IsAuthenticated { get; }
User User { get; }
Guid AccessToken { get; }

View File

@@ -4,20 +4,12 @@ using Microsoft.AspNetCore.Http;
namespace HopFrame.Security.Claims;
public class TokenContextImplementor : ITokenContextBase {
private readonly IHttpContextAccessor _accessor;
private readonly HopDbContextBase _context;
internal class TokenContextImplementor<TDbContext>(IHttpContextAccessor accessor, TDbContext context) : ITokenContext where TDbContext : HopDbContextBase {
public bool IsAuthenticated => accessor.HttpContext?.User.Identity?.IsAuthenticated == true;
public TokenContextImplementor(IHttpContextAccessor accessor, HopDbContextBase context) {
_accessor = accessor;
_context = context;
}
public User User => context.Users
.SingleOrDefault(user => user.Id == accessor.HttpContext.User.GetUserId())?
.ToUserModel(context);
public bool IsAuthenticated => _accessor.HttpContext?.User.Identity?.IsAuthenticated == true;
public User User => _context.Users
.SingleOrDefault(user => user.Id == _accessor.HttpContext.User.GetUserId())?
.ToUserModel(_context);
public Guid AccessToken => Guid.Parse(_accessor.HttpContext?.User.GetAccessTokenId() ?? string.Empty);
public Guid AccessToken => Guid.Parse(accessor.HttpContext?.User.GetAccessTokenId() ?? string.Empty);
}

View File

@@ -4,5 +4,5 @@ A simple backend management api for ASP.NET Core Web APIs
# Features
- [x] Database management
- [x] User authentication
- [ ] Permission management
- [x] Permission management
- [ ] Frontend dashboards