diff --git a/DatabaseTest/.gitignore b/DatabaseTest/.gitignore index dc4e102..15d40be 100644 --- a/DatabaseTest/.gitignore +++ b/DatabaseTest/.gitignore @@ -2,4 +2,4 @@ bin Migrations appsettings.Development.json -test.db \ No newline at end of file +test.db diff --git a/DatabaseTest/Controllers/TestController.cs b/DatabaseTest/Controllers/TestController.cs index 0104631..148829b 100644 --- a/DatabaseTest/Controllers/TestController.cs +++ b/DatabaseTest/Controllers/TestController.cs @@ -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(context) { +public class TestController(DatabaseContext context, ITokenContext userContext) : SecurityController(context) { + + [HttpGet("permissions"), Authorized] + public ActionResult> Permissions() { + return new ActionResult>(userContext.User.Permissions); + } } \ No newline at end of file diff --git a/DatabaseTest/test.db b/DatabaseTest/test.db new file mode 100644 index 0000000..37ca2d0 Binary files /dev/null and b/DatabaseTest/test.db differ diff --git a/HopFrame.Api/Controller/SecurityController.cs b/HopFrame.Api/Controller/SecurityController.cs index a9022b1..3c552f1 100644 --- a/HopFrame.Api/Controller/SecurityController.cs +++ b/HopFrame.Api/Controller/SecurityController.cs @@ -20,15 +20,15 @@ public class SecurityController(TDbContext context) : ControllerBase private const string RefreshTokenType = "HopFrame.Security.RefreshToken"; [HttpPut("login")] - public async Task>> Login([FromBody] UserLogin login) { + public async Task>> Login([FromBody] UserLogin login) { var user = await context.Users.SingleOrDefaultAsync(user => user.Email == login.Email); if (user is null) - return LogicResult>.NotFound("The provided email address was not found"); + return this.FromLogicResult(LogicResult>.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>.Forbidden("The provided password is not correct"); + return this.FromLogicResult(LogicResult>.Forbidden("The provided password is not correct")); var refreshToken = new TokenEntry { CreatedAt = DateTime.Now, @@ -52,15 +52,16 @@ public class SecurityController(TDbContext context) : ControllerBase await context.Tokens.AddRangeAsync(refreshToken, accessToken); await context.SaveChangesAsync(); - return LogicResult>.Ok(accessToken.Token); + return this.FromLogicResult(LogicResult>.Ok(accessToken.Token)); } [HttpPost("register")] - public async Task>> Register([FromBody] UserRegister register) { - //TODO: Validate Password requirements + public async Task>> Register([FromBody] UserRegister register) { + if (register.Password.Length < 8) + return this.FromLogicResult(LogicResult>.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>.Conflict("Username or Email is already registered"); + return this.FromLogicResult(LogicResult>.Conflict("Username or Email is already registered")); var user = new UserEntry { CreatedAt = DateTime.Now, @@ -71,6 +72,17 @@ public class SecurityController(TDbContext context) : ControllerBase user.Password = EncryptionManager.Hash(register.Password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture))); 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, @@ -94,23 +106,23 @@ public class SecurityController(TDbContext context) : ControllerBase await context.Tokens.AddRangeAsync(refreshToken, accessToken); await context.SaveChangesAsync(); - return LogicResult>.Ok(accessToken.Token); + return this.FromLogicResult(LogicResult>.Ok(accessToken.Token)); } [HttpGet("authenticate")] - public async Task>> Authenticate() { + public async Task>> Authenticate() { var refreshToken = HttpContext.Request.Cookies[RefreshTokenType]; if (string.IsNullOrEmpty(refreshToken)) - return LogicResult>.Conflict("Refresh token not provided"); + return this.FromLogicResult(LogicResult>.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>.NotFound("Refresh token not valid"); + return this.FromLogicResult(LogicResult>.NotFound("Refresh token not valid")); if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) - return LogicResult>.Conflict("Refresh token is expired"); + return this.FromLogicResult(LogicResult>.Conflict("Refresh token is expired")); var accessToken = new TokenEntry { CreatedAt = DateTime.Now, @@ -122,16 +134,16 @@ public class SecurityController(TDbContext context) : ControllerBase await context.Tokens.AddAsync(accessToken); await context.SaveChangesAsync(); - return LogicResult>.Ok(accessToken.Token); + return this.FromLogicResult(LogicResult>.Ok(accessToken.Token)); } [HttpDelete("logout"), Authorized] - public async Task Logout() { + public async Task 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 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 context) : ControllerBase HttpContext.Response.Cookies.Delete(RefreshTokenType); - return LogicResult.Ok(); + return this.FromLogicResult(LogicResult.Ok()); } [HttpDelete("delete"), Authorized] - public async Task Delete([FromBody] UserLogin login) { + public async Task 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()); } } \ No newline at end of file diff --git a/HopFrame.Api/Logic/LogicResult.cs b/HopFrame.Api/Logic/LogicResult.cs index cc3254a..eabc02b 100644 --- a/HopFrame.Api/Logic/LogicResult.cs +++ b/HopFrame.Api/Logic/LogicResult.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Mvc; + namespace HopFrame.Api.Logic; public class LogicResult : ILogicResult { diff --git a/HopFrame.Database/HopDbContextBase.cs b/HopFrame.Database/HopDbContextBase.cs index e5872bf..6dd41be 100644 --- a/HopFrame.Database/HopDbContextBase.cs +++ b/HopFrame.Database/HopDbContextBase.cs @@ -12,11 +12,14 @@ public class HopDbContextBase : DbContext { public virtual DbSet Users { get; set; } public virtual DbSet Permissions { get; set; } public virtual DbSet Tokens { get; set; } + public virtual DbSet Groups { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(); modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); } } \ No newline at end of file diff --git a/HopFrame.Database/Models/Entries/GroupEntry.cs b/HopFrame.Database/Models/Entries/GroupEntry.cs new file mode 100644 index 0000000..830d466 --- /dev/null +++ b/HopFrame.Database/Models/Entries/GroupEntry.cs @@ -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; } +} \ No newline at end of file diff --git a/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/HopFrame.Security/Authentication/HopFrameAuthentication.cs index e021a0c..c4f0bfb 100644 --- a/HopFrame.Security/Authentication/HopFrameAuthentication.cs +++ b/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -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,13 +39,24 @@ public class HopFrameAuthentication : AuthenticationHandler { new(HopFrameClaimTypes.AccessTokenId, accessToken), - new(HopFrameClaimTypes.UserId, tokenEntry.UserId.ToString()) + new(HopFrameClaimTypes.UserId, tokenEntry.UserId) }; var permissions = await _context.Permissions .Where(perm => perm.UserId == tokenEntry.UserId) .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))); diff --git a/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs index e6faedc..4b857d4 100644 --- a/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs +++ b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -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(this IServiceCollection service) where TDbContext : HopDbContextBase { + service.TryAddSingleton(); + service.AddScoped>(); return service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme>(HopFrameAuthentication.SchemeName, _ => {}); } diff --git a/HopFrame.Security/Authorization/AuthorizedFilter.cs b/HopFrame.Security/Authorization/AuthorizedFilter.cs index e7f8859..85c8e87 100644 --- a/HopFrame.Security/Authorization/AuthorizedFilter.cs +++ b/HopFrame.Security/Authorization/AuthorizedFilter.cs @@ -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; + } } } \ No newline at end of file diff --git a/HopFrame.Security/Authorization/PermissionValidator.cs b/HopFrame.Security/Authorization/PermissionValidator.cs new file mode 100644 index 0000000..70fd695 --- /dev/null +++ b/HopFrame.Security/Authorization/PermissionValidator.cs @@ -0,0 +1,30 @@ +namespace HopFrame.Security.Authorization; + +internal static class PermissionValidator { + + /// + /// Checks for the user to have the specified permission
+ /// Permission system:
+ /// - "*" -> all rights
+ /// - "group.[name]" -> group member
+ /// - "[namespace].[name]" -> single permission
+ /// - "[namespace].*" -> all permissions in the namespace + ///
+ /// The permission the user needs + /// All the permissions the user has (includes group permissions) + /// + 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; + } + +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/ITokenContextBase.cs b/HopFrame.Security/Claims/ITokenContext.cs similarity index 81% rename from HopFrame.Security/Claims/ITokenContextBase.cs rename to HopFrame.Security/Claims/ITokenContext.cs index 66788bc..4a28c12 100644 --- a/HopFrame.Security/Claims/ITokenContextBase.cs +++ b/HopFrame.Security/Claims/ITokenContext.cs @@ -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; } diff --git a/HopFrame.Security/Claims/TokenContextImplementor.cs b/HopFrame.Security/Claims/TokenContextImplementor.cs index a66d40b..9546f08 100644 --- a/HopFrame.Security/Claims/TokenContextImplementor.cs +++ b/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -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(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 bool IsAuthenticated => _accessor.HttpContext?.User.Identity?.IsAuthenticated == true; - - public User User => _context.Users - .SingleOrDefault(user => user.Id == _accessor.HttpContext.User.GetUserId())? - .ToUserModel(_context); + 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); } \ No newline at end of file diff --git a/README.md b/README.md index e059e97..ba9913e 100644 --- a/README.md +++ b/README.md @@ -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