Reorganized folder structure

This commit is contained in:
2024-09-26 10:20:30 +02:00
parent af7385678f
commit 27088f8217
92 changed files with 16 additions and 31 deletions

View File

@@ -0,0 +1,176 @@
using HopFrame.Api.Logic;
using HopFrame.Api.Models;
using HopFrame.Database;
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Authentication;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using HopFrame.Security.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Api.Controller;
[ApiController]
[Route("authentication")]
public class SecurityController<TDbContext>(TDbContext context, IUserService users, ITokenContext tokenContext) : ControllerBase where TDbContext : HopDbContextBase {
[HttpPut("login")]
public async Task<ActionResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) {
var user = await users.GetUserByEmail(login.Email);
if (user is null)
return LogicResult<SingleValueResult<string>>.NotFound("The provided email address was not found");
if (!await users.CheckUserPassword(user, login.Password))
return LogicResult<SingleValueResult<string>>.Forbidden("The provided password is not correct");
var refreshToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.RefreshTokenType,
UserId = user.Id.ToString()
};
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = user.Id.ToString()
};
HttpContext.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime,
HttpOnly = true,
Secure = true
});
await context.Tokens.AddRangeAsync(refreshToken, accessToken);
await context.SaveChangesAsync();
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
}
[HttpPost("register")]
public async Task<ActionResult<SingleValueResult<string>>> Register([FromBody] UserRegister register) {
if (register.Password.Length < 8)
return LogicResult<SingleValueResult<string>>.Conflict("Password needs to be at least 8 characters long");
var allUsers = await users.GetUsers();
if (allUsers.Any(user => user.Username == register.Username || user.Email == register.Email))
return LogicResult<SingleValueResult<string>>.Conflict("Username or Email is already registered");
var user = await users.AddUser(register);
var refreshToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.RefreshTokenType,
UserId = user.Id.ToString()
};
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = user.Id.ToString()
};
await context.Tokens.AddRangeAsync(refreshToken, accessToken);
await context.SaveChangesAsync();
HttpContext.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime,
HttpOnly = false,
Secure = true
});
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
}
[HttpGet("authenticate")]
public async Task<ActionResult<SingleValueResult<string>>> Authenticate() {
var refreshToken = HttpContext.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(refreshToken))
return 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");
if (token.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now)
return LogicResult<SingleValueResult<string>>.Conflict("Refresh token is expired");
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = token.UserId
};
await context.Tokens.AddAsync(accessToken);
await context.SaveChangesAsync();
HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime,
HttpOnly = false,
Secure = true
});
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Token);
}
[HttpDelete("logout"), Authorized]
public async Task<ActionResult> Logout() {
var accessToken = HttpContext.User.GetAccessTokenId();
var refreshToken = HttpContext.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return LogicResult.Conflict("access or refresh token not provided");
var tokenEntries = await context.Tokens.Where(token =>
(token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) ||
(token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType))
.ToArrayAsync();
if (tokenEntries.Length != 2)
return LogicResult.NotFound("One or more of the provided tokens was not found");
context.Tokens.Remove(tokenEntries[0]);
context.Tokens.Remove(tokenEntries[1]);
await context.SaveChangesAsync();
HttpContext.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
HttpContext.Response.Cookies.Delete(ITokenContext.AccessTokenType);
return LogicResult.Ok();
}
[HttpDelete("delete"), Authorized]
public async Task<ActionResult> Delete([FromBody] UserPasswordValidation validation) {
var user = tokenContext.User;
if (!await users.CheckUserPassword(user, validation.Password))
return LogicResult.Forbidden("The provided password is not correct");
await users.DeleteUser(user);
HttpContext.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
HttpContext.Response.Cookies.Delete(ITokenContext.AccessTokenType);
return LogicResult.Ok();
}
}

View File

@@ -0,0 +1,86 @@
//Source: https://gist.github.com/damianh/5d69be0e3004024f03b6cc876d7b0bd3
using System.Reflection;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.DependencyInjection;
using IMvcCoreBuilder = Microsoft.Extensions.DependencyInjection.IMvcCoreBuilder;
namespace HopFrame.Api.Extensions;
public static class MvcExtensions {
/// <summary>
/// Finds the appropriate controllers
/// </summary>
/// <param name="partManager">The manager for the parts</param>
/// <param name="controllerTypes">The controller types that are allowed. </param>
public static void UseSpecificControllers(this ApplicationPartManager partManager, params Type[] controllerTypes) {
partManager.FeatureProviders.Add(new InternalControllerFeatureProvider());
//partManager.ApplicationParts.Clear();
partManager.ApplicationParts.Add(new SelectedControllersApplicationParts(controllerTypes));
}
/// <summary>
/// Only allow selected controllers
/// </summary>
/// <param name="mvcCoreBuilder">The builder that configures mvc core</param>
/// <param name="controllerTypes">The controller types that are allowed. </param>
public static IMvcCoreBuilder
UseSpecificControllers(this IMvcCoreBuilder mvcCoreBuilder, params Type[] controllerTypes) =>
mvcCoreBuilder.ConfigureApplicationPartManager(partManager =>
partManager.UseSpecificControllers(controllerTypes));
/// <summary>
/// Only instantiates selected controllers, not all of them. Prevents application scanning for controllers.
/// </summary>
private class SelectedControllersApplicationParts : ApplicationPart, IApplicationPartTypeProvider {
public SelectedControllersApplicationParts() {
Name = "Only allow selected controllers";
}
public SelectedControllersApplicationParts(Type[] types) {
Types = types.Select(x => x.GetTypeInfo()).ToArray();
}
public override string Name { get; }
public IEnumerable<TypeInfo> Types { get; }
}
/// <summary>
/// Ensure that internal controllers are also allowed. The default ControllerFeatureProvider hides internal controllers, but this one allows it.
/// </summary>
private class InternalControllerFeatureProvider : ControllerFeatureProvider {
private const string ControllerTypeNameSuffix = "Controller";
/// <summary>
/// Determines if a given <paramref name="typeInfo"/> is a controller. The default ControllerFeatureProvider hides internal controllers, but this one allows it.
/// </summary>
/// <param name="typeInfo">The <see cref="TypeInfo"/> candidate.</param>
/// <returns><code>true</code> if the type is a controller; otherwise <code>false</code>.</returns>
protected override bool IsController(TypeInfo typeInfo) {
if (!typeInfo.IsClass) {
return false;
}
if (typeInfo.IsAbstract) {
return false;
}
if (typeInfo.ContainsGenericParameters) {
return false;
}
if (typeInfo.IsDefined(typeof(Microsoft.AspNetCore.Mvc.NonControllerAttribute))) {
return false;
}
if (!typeInfo.Name.EndsWith(ControllerTypeNameSuffix, StringComparison.OrdinalIgnoreCase) &&
!typeInfo.IsDefined(typeof(Microsoft.AspNetCore.Mvc.ControllerAttribute))) {
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,20 @@
using HopFrame.Api.Controller;
using HopFrame.Database;
using HopFrame.Security.Authentication;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Api.Extensions;
public static class ServiceCollectionExtensions {
/// <summary>
/// Adds all HopFrame endpoints and the HopFrame security layer to the WebApplication
/// </summary>
/// <param name="services">The service provider to add the services to</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrame<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase {
services.AddMvcCore().UseSpecificControllers(typeof(SecurityController<TDbContext>));
services.AddHopFrameAuthentication<TDbContext>();
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Database\HopFrame.Database.csproj" />
<ProjectReference Include="..\HopFrame.Security\HopFrame.Security.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using System.Net;
namespace HopFrame.Api.Logic;
public interface ILogicResult {
HttpStatusCode State { get; set; }
string Message { get; set; }
bool IsSuccessful { get; }
}
public interface ILogicResult<T> {
HttpStatusCode State { get; set; }
T Data { get; set; }
string Message { get; set; }
bool IsSuccessful { get; }
}

View File

@@ -0,0 +1,189 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Api.Logic;
public class LogicResult : ILogicResult {
public HttpStatusCode State { get; set; }
public string Message { get; set; }
public bool IsSuccessful => State == HttpStatusCode.OK;
public static LogicResult Ok() {
return new LogicResult() {
State = HttpStatusCode.OK
};
}
public static LogicResult BadRequest() {
return new LogicResult() {
State = HttpStatusCode.BadRequest
};
}
public static LogicResult BadRequest(string message) {
return new LogicResult() {
State = HttpStatusCode.BadRequest,
Message = message
};
}
public static LogicResult Forbidden() {
return new LogicResult() {
State = HttpStatusCode.Forbidden
};
}
public static LogicResult Forbidden(string message) {
return new LogicResult() {
State = HttpStatusCode.Forbidden,
Message = message
};
}
public static LogicResult NotFound() {
return new LogicResult() {
State = HttpStatusCode.NotFound
};
}
public static LogicResult NotFound(string message) {
return new LogicResult() {
State = HttpStatusCode.NotFound,
Message = message
};
}
public static LogicResult Conflict() {
return new LogicResult() {
State = HttpStatusCode.Conflict
};
}
public static LogicResult Conflict(string message) {
return new LogicResult() {
State = HttpStatusCode.Conflict,
Message = message
};
}
public static LogicResult Forward(LogicResult result) {
return new LogicResult() {
State = result.State,
Message = result.Message
};
}
public static LogicResult Forward<T>(ILogicResult<T> result) {
return new LogicResult() {
State = result.State,
Message = result.Message
};
}
public static implicit operator ActionResult(LogicResult v) {
if (v.State == HttpStatusCode.OK) return new OkResult();
return new ObjectResult(v.Message) {
StatusCode = (int)v.State
};
}
}
public class LogicResult<T> : ILogicResult<T> {
public HttpStatusCode State { get; set; }
public T Data { get; set; }
public string Message { get; set; }
public bool IsSuccessful => State == HttpStatusCode.OK;
public static LogicResult<T> Ok() {
return new LogicResult<T>() {
State = HttpStatusCode.OK
};
}
public static LogicResult<T> Ok(T result) {
return new LogicResult<T>() {
State = HttpStatusCode.OK,
Data = result
};
}
public static LogicResult<T> BadRequest() {
return new LogicResult<T>() {
State = HttpStatusCode.BadRequest
};
}
public static LogicResult<T> BadRequest(string message) {
return new LogicResult<T>() {
State = HttpStatusCode.BadRequest,
Message = message
};
}
public static LogicResult<T> Forbidden() {
return new LogicResult<T>() {
State = HttpStatusCode.Forbidden
};
}
public static LogicResult<T> Forbidden(string message) {
return new LogicResult<T>() {
State = HttpStatusCode.Forbidden,
Message = message
};
}
public static LogicResult<T> NotFound() {
return new LogicResult<T>() {
State = HttpStatusCode.NotFound
};
}
public static LogicResult<T> NotFound(string message) {
return new LogicResult<T>() {
State = HttpStatusCode.NotFound,
Message = message
};
}
public static LogicResult<T> Conflict() {
return new LogicResult<T>() {
State = HttpStatusCode.Conflict
};
}
public static LogicResult<T> Conflict(string message) {
return new LogicResult<T>() {
State = HttpStatusCode.Conflict,
Message = message
};
}
public static LogicResult<T> Forward(ILogicResult result) {
return new LogicResult<T>() {
State = result.State,
Message = result.Message
};
}
public static LogicResult<T> Forward<T2>(ILogicResult<T2> result) {
return new LogicResult<T>() {
State = result.State,
Message = result.Message
};
}
public static implicit operator ActionResult<T>(LogicResult<T> v) {
if (v.State == HttpStatusCode.OK) return new OkObjectResult(v.Data);
return new ObjectResult(v.Message) {
StatusCode = (int)v.State
};
}
}

View File

@@ -0,0 +1,13 @@
namespace HopFrame.Api.Models;
public struct SingleValueResult<TValue>(TValue value) {
public TValue Value { get; set; } = value;
public static implicit operator TValue(SingleValueResult<TValue> v) {
return v.Value;
}
public static implicit operator SingleValueResult<TValue>(TValue v) {
return new SingleValueResult<TValue>(v);
}
}

View File

@@ -0,0 +1,5 @@
namespace HopFrame.Api.Models;
public sealed class UserPasswordValidation {
public string Password { get; set; }
}

View File

@@ -0,0 +1,30 @@
# HopFrame API module
This module contains some useful endpoints for user login / register management.
## Ho to use the Web API version
1. Add the HopFrame.Api library to your project:
```
dotnet add package HopFrame.Api
```
2. Create a DbContext that inherits the ``HopDbContext`` and add a data source
```csharp
public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlite("...");
}
}
```
3. Add the DbContext and HopFrame to your services
```csharp
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>();
```

View File

@@ -0,0 +1,32 @@
using HopFrame.Database.Models.Entries;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Database;
/// <summary>
/// This class includes the basic database structure in order for HopFrame to work
/// </summary>
public abstract 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>();
}
/// <summary>
/// Gets executed when a user is deleted through the IUserService from the
/// HopFrame.Security package. You can override this method to also delete
/// related user specific entries in the database
/// </summary>
/// <param name="user"></param>
public virtual void OnUserDelete(UserEntry user) {}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
</Project>

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

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace HopFrame.Database.Models.Entries;
public sealed class PermissionEntry {
[Key, Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long RecordId { get; set; }
[Required, MaxLength(255)]
public string PermissionText { get; set; }
[Required, MinLength(36), MaxLength(36)]
public string UserId { get; set; }
[Required]
public DateTime GrantedAt { get; set; }
}

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models.Entries;
public class TokenEntry {
public const int RefreshTokenType = 0;
public const int AccessTokenType = 1;
/// <summary>
/// Defines the Type of the stored Token
/// 0: Refresh token
/// 1: Access token
/// </summary>
[Required, MinLength(1), MaxLength(1)]
public int Type { get; set; }
[Key, Required, MinLength(36), MaxLength(36)]
public string Token { get; set; }
[Required, MinLength(36), MaxLength(36)]
public string UserId { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace HopFrame.Database.Models.Entries;
public class UserEntry {
[Key, Required, MinLength(36), MaxLength(36)]
public string Id { get; set; }
[MaxLength(50)]
public string Username { get; set; }
[Required, MaxLength(50), EmailAddress]
public string Email { get; set; }
[Required, MinLength(8), MaxLength(255)]
public string Password { get; set; }
[Required]
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,56 @@
using HopFrame.Database.Models.Entries;
namespace HopFrame.Database.Models;
public static class ModelExtensions {
/// <summary>
/// Converts the database model to a friendly user model
/// </summary>
/// <param name="entry">the database model</param>
/// <param name="contextBase">the data source for the permissions and users</param>
/// <returns></returns>
public static User ToUserModel(this UserEntry entry, HopDbContextBase contextBase) {
var user = new User {
Id = Guid.Parse(entry.Id),
Username = entry.Username,
Email = entry.Email,
CreatedAt = entry.CreatedAt
};
user.Permissions = contextBase.Permissions
.Where(perm => perm.UserId == entry.Id)
.Select(perm => perm.ToPermissionModel())
.ToList();
return user;
}
public static Permission ToPermissionModel(this PermissionEntry entry) {
Guid.TryParse(entry.UserId, out var userId);
return new Permission {
Owner = userId,
PermissionName = entry.PermissionText,
GrantedAt = entry.GrantedAt,
Id = entry.RecordId
};
}
public static PermissionGroup ToPermissionGroup(this GroupEntry entry, HopDbContextBase contextBase) {
var group = new PermissionGroup {
Name = entry.Name,
IsDefaultGroup = entry.Default,
Description = entry.Description,
CreatedAt = entry.CreatedAt
};
group.Permissions = contextBase.Permissions
.Where(perm => perm.UserId == group.Name)
.Select(perm => perm.ToPermissionModel())
.ToList();
return group;
}
}

View File

@@ -0,0 +1,10 @@
namespace HopFrame.Database.Models;
public sealed class Permission {
public long Id { get; init; }
public string PermissionName { get; set; }
public Guid Owner { get; set; }
public DateTime GrantedAt { get; set; }
}
public interface IPermissionOwner {}

View File

@@ -0,0 +1,9 @@
namespace HopFrame.Database.Models;
public class PermissionGroup : IPermissionOwner {
public string Name { get; init; }
public bool IsDefaultGroup { get; set; }
public string Description { get; set; }
public DateTime CreatedAt { get; set; }
public IList<Permission> Permissions { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace HopFrame.Database.Models;
public sealed class User : IPermissionOwner {
public Guid Id { get; init; }
public string Username { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
public IList<Permission> Permissions { get; set; }
}

View File

@@ -0,0 +1,2 @@
# HopFrame Database module
This module contains all the logic for the database communication

View File

@@ -0,0 +1,15 @@
namespace HopFrame.Security;
public static class AdminPermissions {
public const string IsAdmin = "hopframe.admin";
public const string ViewUsers = "hopframe.admin.users.view";
public const string EditUser = "hopframe.admin.users.edit";
public const string DeleteUser = "hopframe.admin.users.delete";
public const string AddUser = "hopframe.admin.users.add";
public const string ViewGroups = "hopframe.admin.groups.view";
public const string EditGroup = "hopframe.admin.groups.edit";
public const string DeleteGroup = "hopframe.admin.groups.delete";
public const string AddGroup = "hopframe.admin.groups.add";
}

View File

@@ -0,0 +1,57 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using HopFrame.Database;
using HopFrame.Security.Claims;
using HopFrame.Security.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
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;
public class HopFrameAuthentication<TDbContext>(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
TDbContext context,
IPermissionService perms)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock)
where TDbContext : HopDbContextBase {
public const string SchemeName = "HopCore.Authentication";
public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0);
public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0);
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
var accessToken = Request.Cookies[ITokenContext.AccessTokenType];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"];
if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided");
var tokenEntry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken);
if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
if (tokenEntry.CreatedAt + AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired");
if (!await context.Users.AnyAsync(user => user.Id == tokenEntry.UserId))
return AuthenticateResult.Fail("The provided Access Token does not match any user");
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, accessToken),
new(HopFrameClaimTypes.UserId, tokenEntry.UserId)
};
var permissions = await perms.GetFullPermissions(tokenEntry.UserId);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, SchemeName));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
}

View File

@@ -0,0 +1,32 @@
using HopFrame.Database;
using HopFrame.Security.Claims;
using HopFrame.Security.Services;
using HopFrame.Security.Services.Implementation;
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 {
/// <summary>
/// Configures the WebApplication to use the authentication and authorization of the HopFrame API
/// </summary>
/// <param name="service">The service provider to add the services to</param>
/// <typeparam name="TDbContext">The database object that saves all entities that are important for the security api</typeparam>
/// <returns></returns>
public static IServiceCollection AddHopFrameAuthentication<TDbContext>(this IServiceCollection service) where TDbContext : HopDbContextBase {
service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
service.AddScoped<ITokenContext, TokenContextImplementor<TDbContext>>();
service.AddScoped<IPermissionService, PermissionService<TDbContext>>();
service.AddScoped<IUserService, UserService<TDbContext>>();
service.AddAuthentication(HopFrameAuthentication<TDbContext>.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication<TDbContext>>(HopFrameAuthentication<TDbContext>.SchemeName, _ => {});
service.AddAuthorization();
return service;
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace HopFrame.Security.Authorization;
public class AuthorizedAttribute : TypeFilterAttribute {
/// <summary>
/// If this decorator is present, the endpoint is only accessible if the user provided a valid access token (is logged in)
/// 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="permissions">specifies the permissions the user needs to have in order to access this endpoint</param>
public AuthorizedAttribute(params string[] permissions) : base(typeof(AuthorizedFilter)) {
Arguments = [permissions];
}
}

View File

@@ -0,0 +1,32 @@
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace HopFrame.Security.Authorization;
public class AuthorizedFilter : IAuthorizationFilter {
private readonly string[] _permissions;
public AuthorizedFilter(params string[] permissions) {
_permissions = permissions;
}
public void OnAuthorization(AuthorizationFilterContext context) {
if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return;
if (string.IsNullOrEmpty(context.HttpContext.User.GetAccessTokenId())) {
context.Result = new UnauthorizedResult();
return;
}
if (_permissions.Length == 0) return;
var permissions = context.HttpContext.User.GetPermissions();
if (!_permissions.All(permission => PermissionValidator.IncludesPermission(permission, permissions))) {
context.Result = new UnauthorizedResult();
return;
}
}
}

View File

@@ -0,0 +1,25 @@
namespace HopFrame.Security.Authorization;
public static class PermissionValidator {
public static bool IncludesPermission(string permission, string[] permissions) {
var permLow = permission.ToLower();
var permsLow = permissions.Select(perm => perm.ToLower()).ToArray();
if (permsLow.Any(perm =>
perm == permLow ||
(perm.Length > permLow.Length && perm.StartsWith(permLow) && perm.ToCharArray()[permLow.Length] == '.') ||
perm == "*"))
return true;
foreach (var perm in permsLow) {
if (!perm.EndsWith(".*")) continue;
var permissionGroup = perm.Substring(0, perm.Length - 1);
if (permLow.StartsWith(permissionGroup)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
using System.Security.Claims;
namespace HopFrame.Security.Claims;
public static class HopFrameClaimTypes {
public const string AccessTokenId = "HopFrame.AccessTokenId";
public const string UserId = "HopFrame.UserId";
public const string Permission = "HopFrame.Permission";
public static string GetAccessTokenId(this ClaimsPrincipal principal) {
return principal.FindFirstValue(AccessTokenId);
}
public static string GetUserId(this ClaimsPrincipal principal) {
return principal.FindFirstValue(UserId);
}
public static string[] GetPermissions(this ClaimsPrincipal principal) {
return principal.FindAll(Permission).Select(claim => claim.Value).ToArray();
}
}

View File

@@ -0,0 +1,24 @@
using HopFrame.Database.Models;
namespace HopFrame.Security.Claims;
public interface ITokenContext {
public const string RefreshTokenType = "HopFrame.Security.RefreshToken";
public const string AccessTokenType = "HopFrame.Security.AccessToken";
/// <summary>
/// This field specifies that a valid user is accessing the endpoint
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// The user that is accessing the endpoint
/// </summary>
User User { get; }
/// <summary>
/// The access token the user provided
/// </summary>
Guid AccessToken { get; }
}

View File

@@ -0,0 +1,15 @@
using HopFrame.Database;
using HopFrame.Database.Models;
using Microsoft.AspNetCore.Http;
namespace HopFrame.Security.Claims;
internal sealed class TokenContextImplementor<TDbContext>(IHttpContextAccessor accessor, TDbContext context) : ITokenContext where TDbContext : HopDbContextBase {
public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
public User User => context.Users
.SingleOrDefault(user => user.Id == accessor.HttpContext.User.GetUserId())?
.ToUserModel(context);
public Guid AccessToken => Guid.Parse(accessor.HttpContext?.User.GetAccessTokenId() ?? Guid.Empty.ToString());
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace HopFrame.Security;
public static class EncryptionManager {
/// <summary>
/// Encrypts the given string with the specified hash method
/// </summary>
/// <param name="input">The raw string that should be hashed</param>
/// <param name="salt">The "password" for the hash</param>
/// <param name="method">The preferred hash method</param>
/// <returns></returns>
public static string Hash(string input, byte[] salt, KeyDerivationPrf method = KeyDerivationPrf.HMACSHA256) {
return Convert.ToBase64String(KeyDerivation.Pbkdf2(
password: input,
salt: salt,
prf: method,
iterationCount: 100000,
numBytesRequested: 256 / 8
));
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<RootNamespace>HopFrame.Security</RootNamespace>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Database\HopFrame.Database.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
namespace HopFrame.Security.Models;
public class UserLogin {
public string Email { get; set; }
public string Password { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace HopFrame.Security.Models;
public class UserRegister {
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}

View File

@@ -0,0 +1,2 @@
# HopFrame Security module
this module contains all handlers for the login and register validation. It also checks the user permissions.

View File

@@ -0,0 +1,48 @@
using HopFrame.Database.Models;
namespace HopFrame.Security.Services;
/// <summary>
/// permission system:<br/>
/// - "*" -> all rights<br/>
/// - "group.[name]" -> group member<br/>
/// - "[namespace].[name]" -> single permission<br/>
/// - "[namespace].*" -> all permissions in the namespace
/// </summary>
public interface IPermissionService {
Task<bool> HasPermission(string permission, Guid user);
Task<IList<PermissionGroup>> GetPermissionGroups();
Task<PermissionGroup> GetPermissionGroup(string name);
Task EditPermissionGroup(PermissionGroup group);
Task<IList<PermissionGroup>> GetUserPermissionGroups(User user);
Task RemoveGroupFromUser(User user, PermissionGroup group);
Task<PermissionGroup> CreatePermissionGroup(string name, bool isDefault = false, string description = null);
Task DeletePermissionGroup(PermissionGroup group);
Task<Permission> GetPermission(string name, IPermissionOwner owner);
/// <summary>
/// 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="owner"></param>
/// <param name="permission"></param>
/// <returns></returns>
Task AddPermission(IPermissionOwner owner, string permission);
Task RemovePermission(Permission permission);
Task<string[]> GetFullPermissions(string user);
}

View File

@@ -0,0 +1,29 @@
using HopFrame.Database.Models;
using HopFrame.Security.Models;
namespace HopFrame.Security.Services;
public interface IUserService {
Task<IList<User>> GetUsers();
Task<User> GetUser(Guid userId);
Task<User> GetUserByEmail(string email);
Task<User> GetUserByUsername(string username);
Task<User> AddUser(UserRegister user);
/// <summary>
/// IMPORTANT:<br/>
/// This function does not add or remove any permissions to the user.
/// For that please use <see cref="IPermissionService"/>
/// </summary>
Task UpdateUser(User user);
Task DeleteUser(User user);
Task<bool> CheckUserPassword(User user, string password);
Task ChangePassword(User user, string password);
}

View File

@@ -0,0 +1,178 @@
using HopFrame.Database;
using HopFrame.Database.Models;
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Security.Services.Implementation;
internal sealed class PermissionService<TDbContext>(TDbContext context, ITokenContext current) : IPermissionService where TDbContext : HopDbContextBase {
public async Task<bool> HasPermission(string permission) {
return await HasPermission(permission, current.User.Id);
}
public async Task<bool> HasPermissions(params string[] permissions) {
var user = current.User.Id.ToString();
var perms = await GetFullPermissions(user);
foreach (var permission in permissions) {
if (!PermissionValidator.IncludesPermission(permission, perms)) return false;
}
return true;
}
public async Task<bool> HasAnyPermission(params string[] permissions) {
var user = current.User.Id.ToString();
var perms = await GetFullPermissions(user);
foreach (var permission in permissions) {
if (PermissionValidator.IncludesPermission(permission, perms)) return true;
}
return false;
}
public async Task<bool> HasPermission(string permission, Guid user) {
var permissions = await GetFullPermissions(user.ToString());
return PermissionValidator.IncludesPermission(permission, permissions);
}
public async Task<IList<PermissionGroup>> GetPermissionGroups() {
return await context.Groups
.Select(group => group.ToPermissionGroup(context))
.ToListAsync();
}
public Task<PermissionGroup> GetPermissionGroup(string name) {
return context.Groups
.Where(group => group.Name == name)
.Select(group => group.ToPermissionGroup(context))
.SingleOrDefaultAsync();
}
public async Task EditPermissionGroup(PermissionGroup group) {
var orig = await context.Groups.SingleOrDefaultAsync(g => g.Name == group.Name);
if (orig is null) return;
var entity = context.Groups.Update(orig);
entity.Entity.Default = group.IsDefaultGroup;
entity.Entity.Description = group.Description;
await context.SaveChangesAsync();
}
public async Task<IList<PermissionGroup>> GetUserPermissionGroups(User user) {
var groups = await context.Groups.ToListAsync();
var perms = await GetFullPermissions(user.Id.ToString());
return groups
.Where(group => perms.Contains(group.Name))
.Select(group => group.ToPermissionGroup(context))
.ToList();
}
public async Task RemoveGroupFromUser(User user, PermissionGroup group) {
var entry = await context.Permissions
.Where(perm => perm.PermissionText == group.Name && perm.UserId == user.Id.ToString())
.SingleOrDefaultAsync();
if (entry is null) return;
context.Permissions.Remove(entry);
await context.SaveChangesAsync();
}
public async Task<PermissionGroup> CreatePermissionGroup(string name, bool isDefault = false, string description = null) {
var group = new GroupEntry {
Name = name,
Description = description,
Default = isDefault,
CreatedAt = DateTime.Now
};
await context.Groups.AddAsync(group);
if (isDefault) {
var users = await context.Users.ToListAsync();
foreach (var user in users) {
await context.Permissions.AddAsync(new PermissionEntry {
GrantedAt = DateTime.Now,
PermissionText = group.Name,
UserId = user.Id
});
}
}
await context.SaveChangesAsync();
return group.ToPermissionGroup(context);
}
public async Task DeletePermissionGroup(PermissionGroup group) {
var entry = await context.Groups.SingleOrDefaultAsync(entry => entry.Name == group.Name);
context.Groups.Remove(entry);
var permissions = await context.Permissions
.Where(perm => perm.UserId == group.Name || perm.PermissionText == group.Name)
.ToListAsync();
if (permissions.Count > 0) {
context.Permissions.RemoveRange(permissions);
}
await context.SaveChangesAsync();
}
public async Task<Permission> GetPermission(string name, IPermissionOwner owner) {
var ownerId = (owner is User user) ? user.Id.ToString() : ((PermissionGroup)owner).Name;
return await context.Permissions
.Where(perm => perm.PermissionText == name && perm.UserId == ownerId)
.Select(perm => perm.ToPermissionModel())
.SingleOrDefaultAsync();
}
public async Task AddPermission(IPermissionOwner owner, string permission) {
var userId = owner is User user ? user.Id.ToString() : (owner as PermissionGroup)?.Name;
await context.Permissions.AddAsync(new PermissionEntry {
UserId = userId,
PermissionText = permission,
GrantedAt = DateTime.Now
});
await context.SaveChangesAsync();
}
public async Task RemovePermission(Permission permission) {
var entry = await context.Permissions.SingleOrDefaultAsync(entry => entry.RecordId == permission.Id);
context.Permissions.Remove(entry);
await context.SaveChangesAsync();
}
public async Task<string[]> GetFullPermissions(string user) {
var permissions = await context.Permissions
.Where(perm => perm.UserId == user)
.Select(perm => perm.PermissionText)
.ToListAsync();
var groups = permissions
.Where(perm => perm.StartsWith("group."))
.ToList();
var groupPerms = new List<string>();
foreach (var group in groups) {
var perms = await GetFullPermissions(group);
groupPerms.AddRange(perms);
}
permissions.AddRange(groupPerms);
return permissions.ToArray();
}
}

View File

@@ -0,0 +1,128 @@
using System.Globalization;
using System.Text;
using HopFrame.Database;
using HopFrame.Database.Models;
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Models;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Security.Services.Implementation;
internal sealed class UserService<TDbContext>(TDbContext context) : IUserService where TDbContext : HopDbContextBase {
public async Task<IList<User>> GetUsers() {
return await context.Users
.Select(user => user.ToUserModel(context))
.ToListAsync();
}
public Task<User> GetUser(Guid userId) {
var id = userId.ToString();
return context.Users
.Where(user => user.Id == id)
.Select(user => user.ToUserModel(context))
.SingleOrDefaultAsync();
}
public Task<User> GetUserByEmail(string email) {
return context.Users
.Where(user => user.Email == email)
.Select(user => user.ToUserModel(context))
.SingleOrDefaultAsync();
}
public Task<User> GetUserByUsername(string username) {
return context.Users
.Where(user => user.Username == username)
.Select(user => user.ToUserModel(context))
.SingleOrDefaultAsync();
}
public async Task<User> AddUser(UserRegister user) {
if (await GetUserByEmail(user.Email) is not null) return null;
if (await GetUserByUsername(user.Username) is not null) return null;
var entry = new UserEntry {
Id = Guid.NewGuid().ToString(),
Email = user.Email,
Username = user.Username,
CreatedAt = DateTime.Now
};
entry.Password = EncryptionManager.Hash(user.Password, Encoding.Default.GetBytes(entry.CreatedAt.ToString(CultureInfo.InvariantCulture)));
await context.Users.AddAsync(entry);
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 = entry.Id
}));
await context.SaveChangesAsync();
return entry.ToUserModel(context);
}
public async Task UpdateUser(User user) {
var id = user.Id.ToString();
var entry = await context.Users
.SingleOrDefaultAsync(entry => entry.Id == id);
if (entry is null) return;
entry.Email = user.Email;
entry.Username = user.Username;
await context.SaveChangesAsync();
}
public async Task DeleteUser(User user) {
var id = user.Id.ToString();
var entry = await context.Users
.SingleOrDefaultAsync(entry => entry.Id == id);
if (entry is null) return;
context.Users.Remove(entry);
var userTokens = await context.Tokens
.Where(token => token.UserId == id)
.ToArrayAsync();
context.Tokens.RemoveRange(userTokens);
var userPermissions = await context.Permissions
.Where(perm => perm.UserId == id)
.ToArrayAsync();
context.Permissions.RemoveRange(userPermissions);
context.OnUserDelete(entry);
await context.SaveChangesAsync();
}
public async Task<bool> CheckUserPassword(User user, string password) {
var id = user.Id.ToString();
var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
var entry = await context.Users
.Where(entry => entry.Id == id)
.SingleOrDefaultAsync();
return entry.Password == hash;
}
public async Task ChangePassword(User user, string password) {
var entry = await context.Users
.Where(entry => entry.Id == user.Id.ToString())
.SingleOrDefaultAsync();
if (entry is null) return;
var hash = EncryptionManager.Hash(password, Encoding.Default.GetBytes(user.CreatedAt.ToString(CultureInfo.InvariantCulture)));
entry.Password = hash;
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,16 @@
namespace HopFrame.Web;
[Obsolete("Use HopFrame.Security.AdminPermissions instead")]
public static class AdminPermissions {
public const string IsAdmin = Security.AdminPermissions.IsAdmin;
public const string ViewUsers = Security.AdminPermissions.ViewUsers;
public const string EditUser = Security.AdminPermissions.EditUser;
public const string DeleteUser = Security.AdminPermissions.DeleteUser;
public const string AddUser = Security.AdminPermissions.AddUser;
public const string ViewGroups = Security.AdminPermissions.ViewGroups;
public const string EditGroup = Security.AdminPermissions.EditGroup;
public const string DeleteGroup = Security.AdminPermissions.DeleteGroup;
public const string AddGroup = Security.AdminPermissions.AddGroup;
}

View File

@@ -0,0 +1,35 @@
using System.Security.Claims;
using HopFrame.Database;
using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Services;
using HopFrame.Web.Services;
using Microsoft.AspNetCore.Http;
namespace HopFrame.Web;
public sealed class AuthMiddleware(IAuthService auth, IPermissionService perms) : IMiddleware {
public async Task InvokeAsync(HttpContext context, RequestDelegate next) {
var loggedIn = await auth.IsLoggedIn();
if (!loggedIn) {
var token = await auth.RefreshLogin();
if (token is null) {
await next.Invoke(context);
return;
}
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, token.Token),
new(HopFrameClaimTypes.UserId, token.UserId)
};
var permissions = await perms.GetFullPermissions(token.UserId);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication<HopDbContextBase>.SchemeName));
}
await next?.Invoke(context);
}
}

View File

@@ -0,0 +1,290 @@
@rendermode InteractiveServer
@using BlazorStrap
@using BlazorStrap.Shared.Components.Modal
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using BlazorStrap.V5
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Claims
@using HopFrame.Security.Services
@using HopFrame.Web.Model
<BSModal DataId="add-group-modal" HideOnValidSubmit="true" IsStaticBackdrop="true" @ref="_modal">
<BSForm Model="_group" OnValidSubmit="AddGroup">
@if (_isEdit) {
<BSModalHeader>Edit group</BSModalHeader>
}
else {
<BSModalHeader>Add group</BSModalHeader>
}
<BSModalContent>
<div class="mb-3">
<BSLabel>Name</BSLabel>
@if (!_isEdit) {
<BSInputGroup>
<span class="@BS.Input_Group_Text">group.</span>
<BSInput InputType="InputType.Text" @bind-Value="_group.GroupName" required/>
</BSInputGroup>
}
else {
<input type="text" class="form-control" disabled value="@_group.Name"/>
}
</div>
@if (_isEdit) {
<div class="mb-3">
<BSLabel>Created at</BSLabel>
<input type="text" class="form-control" disabled value="@_group.CreatedAt"/>
</div>
}
<div class="mb-3">
<BSLabel>Description</BSLabel>
<BSInput InputType="InputType.TextArea" @bind-Value="_group.Description"/>
</div>
<div class="mb-3">
<BSInputSwitch @bind-Value="_group.IsDefaultGroup" CheckedValue="true" UnCheckedValue="false">
Default group
</BSInputSwitch>
</div>
<div class="mb-3">
<BSLabel>Inherits from</BSLabel>
<BSListGroup>
<BSListGroupItem>
<BSListGroup IsFlush="true">
@foreach (var group in _group.Permissions.Where(g => g.PermissionName.StartsWith("group."))) {
<BSListGroupItem>
<BSButton Color="BSColor.Danger" Size="Size.ExtraSmall" MarginEnd="Margins.Small" OnClick="() => RemovePermission(group)">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Cross"/>
</BSButton>
<span>@group.PermissionName.Replace("group.", "")</span>
</BSListGroupItem>
}
</BSListGroup>
</BSListGroupItem>
<BSListGroupItem>
<div style="display: flex; gap: 20px">
<BSInput InputType="InputType.Select" @bind-Value="_groupToAdd">
<option selected>Select group</option>
@foreach (var group in _allGroups) {
@if (_group.Permissions.All(g => g.PermissionName != group.Name) && group.Name != _group.Name) {
<option value="@group.Name">@group.Name.Replace("group.", "")</option>
}
}
</BSInput>
<BSButton Color="BSColor.Secondary" OnClick="AddInheritanceGroup">Add</BSButton>
</div>
</BSListGroupItem>
</BSListGroup>
</div>
<div class="mb-3">
<BSLabel>Permissions</BSLabel>
<BSListGroup>
<BSListGroupItem>
<BSListGroup IsFlush="true">
@foreach (var perm in _group.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) {
<BSListGroupItem>
<BSButton Color="BSColor.Danger" Size="Size.ExtraSmall" MarginEnd="Margins.Small" OnClick="() => RemovePermission(perm)">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Cross"/>
</BSButton>
<span>@perm.PermissionName</span>
</BSListGroupItem>
}
</BSListGroup>
</BSListGroupItem>
<BSListGroupItem>
<div style="display: flex; gap: 20px">
<BSInput InputType="InputType.Text" @bind-Value="_permissionToAdd"/>
<BSButton Color="BSColor.Secondary" OnClick="AddPermission">Add</BSButton>
</div>
</BSListGroupItem>
</BSListGroup>
</div>
</BSModalContent>
<BSModalFooter>
<BSButton Target="add-group-modal">Cancel</BSButton>
<BSButton IsSubmit="true" Color="BSColor.Primary">Save</BSButton>
</BSModalFooter>
</BSForm>
</BSModal>
@inject IPermissionService Permissions
@inject SweetAlertService Alerts
@inject ITokenContext Context
@code {
[Parameter] public Func<Task> ReloadPage { get; set; }
private PermissionGroupAdd _group;
private BSModalBase _modal;
private string _permissionToAdd;
private string _groupToAdd;
private IList<PermissionGroup> _allGroups;
private bool _isEdit;
public async Task ShowAsync(PermissionGroup group = null) {
_allGroups = await Permissions.GetPermissionGroups();
if (group is not null) {
_group = new PermissionGroupAdd {
CreatedAt = group.CreatedAt,
Description = group.Description,
Name = group.Name,
IsDefaultGroup = group.IsDefaultGroup,
Permissions = group.Permissions
};
_isEdit = true;
}
else {
_group = new PermissionGroupAdd {
Permissions = new List<Permission>(),
IsDefaultGroup = false
};
_isEdit = false;
}
await _modal.ShowAsync();
}
private async Task AddPermission() {
if (string.IsNullOrWhiteSpace(_permissionToAdd)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Enter a permission name!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = true
});
return;
}
if (_isEdit) {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditGroup, Context.User.Id))) {
await NoEditPermissions();
return;
}
await Permissions.AddPermission(_group, _permissionToAdd);
}
_group.Permissions.Add(new Permission {
PermissionName = _permissionToAdd
});
_permissionToAdd = null;
}
private async Task RemovePermission(Permission permission) {
if (_isEdit) {
var perm = await Permissions.GetPermission(permission.PermissionName, _group);
await Permissions.RemovePermission(perm);
}
_group.Permissions.Remove(permission);
}
private async Task AddInheritanceGroup() {
if (string.IsNullOrWhiteSpace(_groupToAdd)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Select a group!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = true
});
return;
}
if (_isEdit) {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditGroup, Context.User.Id))) {
await NoEditPermissions();
return;
}
await Permissions.AddPermission(_group, _groupToAdd);
}
_group.Permissions.Add(new Permission {
PermissionName = _groupToAdd
});
_groupToAdd = null;
}
private async Task AddGroup() {
if (_isEdit) {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditGroup, Context.User.Id))) {
await NoEditPermissions();
return;
}
await Permissions.EditPermissionGroup(_group);
if (ReloadPage is not null)
await ReloadPage.Invoke();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Group edited!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
return;
}
if (!(await Permissions.HasPermission(Security.AdminPermissions.AddGroup, Context.User.Id))) {
await NoAddPermissions();
return;
}
if (_allGroups.Any(group => group.Name == _group.Name)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Something went wrong!",
Text = "This group already exists!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = false,
Timer = 1500
});
return;
}
var dbGroup = await Permissions.CreatePermissionGroup("group." + _group.GroupName, _group.IsDefaultGroup, _group.Description);
foreach (var permission in _group.Permissions) {
await Permissions.AddPermission(dbGroup, permission.PermissionName);
}
if (ReloadPage is not null)
await ReloadPage.Invoke();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Group added!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
private async Task NoEditPermissions() {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to edit a group!",
Icon = SweetAlertIcon.Error
});
}
private async Task NoAddPermissions() {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to add a group!",
Icon = SweetAlertIcon.Error
});
}
}

View File

@@ -0,0 +1,76 @@
@switch (Type) {
case HopIcon.Reload:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
</svg>
break;
case HopIcon.ArrowUp:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/>
</svg>
break;
case HopIcon.ArrowDown:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path d="m7.247 4.86-4.796 5.481c-.566.647-.106 1.659.753 1.659h9.592a1 1 0 0 0 .753-1.659l-4.796-5.48a1 1 0 0 0-1.506 0z"/>
</svg>
break;
case HopIcon.Cross:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
</svg>
break;
case HopIcon.User:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
break;
case HopIcon.Group:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/>
</svg>
break;
case HopIcon.Logout:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@GetClass()" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M6 12.5a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-9a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v2a.5.5 0 0 1-1 0v-2A1.5 1.5 0 0 1 6.5 2h8A1.5 1.5 0 0 1 16 3.5v9a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 5 12.5v-2a.5.5 0 0 1 1 0z"/>
<path fill-rule="evenodd" d="M.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L1.707 7.5H10.5a.5.5 0 0 1 0 1H1.707l2.147 2.146a.5.5 0 0 1-.708.708z"/>
</svg>
break;
}
<style>
svg.bi-nav {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
</style>
@code {
[Parameter] public HopIcon Type { get; set; }
[Parameter] public bool NavIcon { get; set; }
public enum HopIcon {
Reload,
ArrowUp,
ArrowDown,
User,
Group,
Logout,
Cross
}
private string GetClass() {
return NavIcon ? "bi-nav" : "bi";
}
}

View File

@@ -0,0 +1,131 @@
@rendermode InteractiveServer
@using BlazorStrap
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using BlazorStrap.Shared.Components.Modal
@using BlazorStrap.V5
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Claims
@using HopFrame.Security.Services
@using HopFrame.Web.Model
<BSModal DataId="add-user-modal" HideOnValidSubmit="true" IsStaticBackdrop="true" OnShow="() => _user = new()" @ref="_modal">
<BSForm Model="_user" OnValidSubmit="AddUser">
<BSModalHeader>Add user</BSModalHeader>
<BSModalContent>
<div class="mb-3">
<BSLabel>E-Mail</BSLabel>
<BSInput InputType="InputType.Email" @bind-Value="_user.Email" required/>
</div>
<div class="mb-3">
<BSLabel>Username</BSLabel>
<BSInput InputType="InputType.Text" @bind-Value="_user.Username" required/>
</div>
<div class="mb-3">
<BSLabel>Password</BSLabel>
<BSInput InputType="InputType.Password" @bind-Value="_user.Password" required/>
</div>
<div class="mb-3">
<BSLabel>Primary group</BSLabel>
<BSInput InputType="InputType.Select" @bind-Value="_user.Group">
<option value="">Select group</option>
@foreach (var group in _allGroups) {
<option value="@group.Name">@group.Name.Replace("group.", "")</option>
}
</BSInput>
</div>
</BSModalContent>
<BSModalFooter>
<BSButton Target="add-user-modal">Cancel</BSButton>
<BSButton IsSubmit="true" Color="BSColor.Primary">Save</BSButton>
</BSModalFooter>
</BSForm>
</BSModal>
@inject IUserService Users
@inject IPermissionService Permissions
@inject SweetAlertService Alerts
@inject ITokenContext Auth
@code {
[Parameter] public Func<Task> ReloadPage { get; set; }
private IList<PermissionGroup> _allGroups = new List<PermissionGroup>();
private IList<User> _allUsers = new List<User>();
private UserAdd _user;
private BSModalBase _modal;
public async Task ShowAsync() {
_allGroups = await Permissions.GetPermissionGroups();
_allUsers = await Users.GetUsers();
await _modal.ShowAsync();
}
private async Task AddUser() {
if (!(await Permissions.HasPermission(Security.AdminPermissions.AddUser, Auth.User.Id))) {
await NoAddPermissions();
return;
}
string errorMessage = null;
if (_allUsers.Any(user => user.Username == _user.Username)) {
errorMessage = "Username is already taken!";
}
else if (_allUsers.Any(user => user.Email == _user.Email)) {
errorMessage = "E-Mail is already taken!";
}
else if (!_user.PasswordIsValid) {
errorMessage = "The password needs to be at least 8 characters long!";
}
else if (!_user.EmailIsValid) {
errorMessage = "Invalid E-Mail address!";
}
else if (string.IsNullOrWhiteSpace(_user.Username)) {
errorMessage = "You need to set a username!";
}
if (!string.IsNullOrWhiteSpace(errorMessage)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Something went wrong!",
Text = errorMessage,
Icon = SweetAlertIcon.Error,
ShowConfirmButton = false,
Timer = 1500
});
return;
}
var user = await Users.AddUser(_user);
if (!string.IsNullOrWhiteSpace(_user.Group)) {
await Permissions.AddPermission(user, _user.Group);
}
await ReloadPage.Invoke();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "New user added!",
Icon = SweetAlertIcon.Success,
ShowConfirmButton = false,
Timer = 1500
});
}
private async Task NoAddPermissions() {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to add a user!",
Icon = SweetAlertIcon.Error
});
}
}

View File

@@ -0,0 +1,306 @@
@rendermode InteractiveServer
@using BlazorStrap
@using BlazorStrap.Shared.Components.Modal
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using BlazorStrap.V5
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Claims
@using HopFrame.Security.Services
@using HopFrame.Web.Model
<BSModal DataId="edit-user-modal" HideOnValidSubmit="true" IsStaticBackdrop="true" @ref="_modal">
<BSForm Model="_user" OnValidSubmit="EditUser">
<BSModalHeader>Edit @_user.Username</BSModalHeader>
<BSModalContent>
<div class="mb-3">
<BSLabel>User id</BSLabel>
<input type="text" class="form-control" disabled value="@_user.Id"/>
</div>
<div class="mb-3">
<BSLabel>Created at</BSLabel>
<input type="text" class="form-control" disabled value="@_user.CreatedAt"/>
</div>
<div class="mb-3">
<BSLabel>E-Mail</BSLabel>
<BSInput InputType="InputType.Email" @bind-Value="_user.Email" required/>
</div>
<div class="mb-3">
<BSLabel>Username</BSLabel>
<BSInput InputType="InputType.Text" @bind-Value="_user.Username" required/>
</div>
<div class="mb-3">
<BSLabel>Password</BSLabel>
<BSInput InputType="InputType.Password" @bind-Value="_newPassword"/>
</div>
<div class="mb-3">
<BSLabel>Groups</BSLabel>
<BSListGroup>
<BSListGroupItem>
<BSListGroup IsFlush="true">
@foreach (var group in _userGroups) {
<BSListGroupItem>
<BSButton Color="BSColor.Danger" Size="Size.ExtraSmall" MarginEnd="Margins.Small" OnClick="() => RemoveGroup(group)">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Cross"/>
</BSButton>
<span>@group.Name.Replace("group.", "")</span>
</BSListGroupItem>
}
</BSListGroup>
</BSListGroupItem>
<BSListGroupItem>
<div style="display: flex; gap: 20px">
<BSInput InputType="InputType.Select" @bind-Value="_selectedGroup">
<option selected>Select group</option>
@foreach (var group in _allGroups) {
@if (_userGroups.All(g => g.Name != group.Name)) {
<option value="@group.Name">@group.Name.Replace("group.", "")</option>
}
}
</BSInput>
<BSButton Color="BSColor.Secondary" OnClick="AddGroup">Add</BSButton>
</div>
</BSListGroupItem>
</BSListGroup>
</div>
<div class="mb-3">
<BSLabel>Permissions</BSLabel>
<BSListGroup>
<BSListGroupItem>
<BSListGroup IsFlush="true">
@foreach (var perm in _user.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) {
<BSListGroupItem>
<BSButton Color="BSColor.Danger" Size="Size.ExtraSmall" MarginEnd="Margins.Small" OnClick="() => RemovePermission(perm)">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Cross"/>
</BSButton>
<span>@perm.PermissionName</span>
</BSListGroupItem>
}
</BSListGroup>
</BSListGroupItem>
<BSListGroupItem>
<div style="display: flex; gap: 20px">
<BSInput InputType="InputType.Text" @bind-Value="_permissionToAdd"/>
<BSButton Color="BSColor.Secondary" OnClick="AddPermission">Add</BSButton>
</div>
</BSListGroupItem>
</BSListGroup>
</div>
</BSModalContent>
<BSModalFooter>
<BSButton Target="edit-user-modal">Cancel</BSButton>
<BSButton IsSubmit="true" Color="BSColor.Primary">Save</BSButton>
</BSModalFooter>
</BSForm>
</BSModal>
@inject IUserService Users
@inject IPermissionService Permissions
@inject SweetAlertService Alerts
@inject ITokenContext Auth
@code {
[Parameter] public Func<Task> ReloadPage { get; set; }
private BSModalBase _modal;
private User _user;
private string _newPassword;
private IList<PermissionGroup> _userGroups;
private IList<PermissionGroup> _allGroups;
private string _selectedGroup;
private string _permissionToAdd;
public async Task ShowAsync(User user) {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id))) {
await NoEditPermissions();
return;
}
_user = user;
_userGroups = await Permissions.GetUserPermissionGroups(_user);
_allGroups = await Permissions.GetPermissionGroups();
await _modal.ShowAsync();
}
private async Task AddGroup() {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id))) {
await NoEditPermissions();
return;
}
if (string.IsNullOrWhiteSpace(_selectedGroup)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Select a group!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = true
});
return;
}
var group = _allGroups.SingleOrDefault(group => group.Name == _selectedGroup);
await Permissions.AddPermission(_user, group?.Name);
_userGroups.Add(group);
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Group added!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
private async Task RemoveGroup(PermissionGroup group) {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id))) {
await NoEditPermissions();
return;
}
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await Permissions.RemoveGroupFromUser(_user, group);
_userGroups.Remove(group);
StateHasChanged();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Group removed!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
}
private async Task AddPermission() {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id))) {
await NoEditPermissions();
return;
}
if (string.IsNullOrWhiteSpace(_permissionToAdd)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Enter a permission name!",
Icon = SweetAlertIcon.Error,
ShowConfirmButton = true
});
return;
}
await Permissions.AddPermission(_user, _permissionToAdd);
_user.Permissions.Add(await Permissions.GetPermission(_permissionToAdd, _user));
_permissionToAdd = "";
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Permission added!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
private async Task RemovePermission(Permission perm) {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id))) {
await NoEditPermissions();
return;
}
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await Permissions.RemovePermission(perm);
_user.Permissions.Remove(perm);
StateHasChanged();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Permission removed!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
}
private async void EditUser() {
if (!(await Permissions.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id))) {
await NoEditPermissions();
return;
}
string errorMessage = null;
var validator = new RegisterData {
Password = _newPassword,
Email = _user.Email
};
var allUsers = await Users.GetUsers();
if (allUsers.Any(user => user.Username == _user.Username && user.Id != _user.Id)) {
errorMessage = "Username is already taken!";
}
else if (allUsers.Any(user => user.Email == _user.Email && user.Id != _user.Id)) {
errorMessage = "E-Mail is already taken!";
}
else if (!string.IsNullOrWhiteSpace(_newPassword) && !validator.PasswordIsValid) {
errorMessage = "The password needs to be at least 8 characters long!";
}
else if (!validator.EmailIsValid) {
errorMessage = "Invalid E-Mail address!";
}
if (!string.IsNullOrWhiteSpace(errorMessage)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Something went wrong!",
Text = errorMessage,
Icon = SweetAlertIcon.Error,
ShowConfirmButton = false,
Timer = 1500
});
return;
}
await Users.UpdateUser(_user);
if (!string.IsNullOrWhiteSpace(_newPassword)) {
await Users.ChangePassword(_user, _newPassword);
}
if (ReloadPage is not null)
await ReloadPage.Invoke();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "User edited!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
private async Task NoEditPermissions() {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to edit a user!",
Icon = SweetAlertIcon.Error
});
}
}

View File

@@ -0,0 +1,49 @@
@using HopFrame.Security.Authorization
@using HopFrame.Security.Claims
@using Microsoft.AspNetCore.Http
@if (HandleComponent()) {
@ChildContent
}
@inject ITokenContext Auth
@inject IHttpContextAccessor HttpAccessor
@inject NavigationManager Navigator
@code {
[Parameter]
public string[] Permissions { get; set; }
[Parameter]
public string Permission { get; set; }
[Parameter]
public string RedirectIfUnauthorized { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
private bool IsAuthorized() {
if (!Auth.IsAuthenticated) return false;
if ((Permissions == null || Permissions.Length == 0) && string.IsNullOrEmpty(Permission)) return true;
Permissions ??= [];
var perms = new List<string>(Permissions);
if (!string.IsNullOrEmpty(Permission)) perms.Add(Permission);
var permissions = HttpAccessor.HttpContext?.User.GetPermissions();
if (!perms.All(perm => PermissionValidator.IncludesPermission(perm, permissions))) return false;
return true;
}
private bool HandleComponent() {
var authorized = IsAuthorized();
if (authorized == false && !string.IsNullOrEmpty(RedirectIfUnauthorized)) {
Navigator.NavigateTo(RedirectIfUnauthorized, true);
}
return authorized;
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Database\HopFrame.Database.csproj" />
<ProjectReference Include="..\HopFrame.Security\HopFrame.Security.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BlazorStrap" Version="5.2.100.61524" />
<PackageReference Include="BlazorStrap.V5" Version="5.2.100" />
<PackageReference Include="CurrieTechnologies.Razor.SweetAlert2" Version="5.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Components" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.7" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
namespace HopFrame.Web.Model;
public sealed class NavigationItem {
public string Name { get; set; }
public string Url { get; set; }
public string Permission { get; set; }
public string Description { get; set; }
}

View File

@@ -0,0 +1,7 @@
using HopFrame.Database.Models;
namespace HopFrame.Web.Model;
internal sealed class PermissionGroupAdd : PermissionGroup {
public string GroupName { get; set; }
}

View File

@@ -0,0 +1,11 @@
using HopFrame.Security.Models;
namespace HopFrame.Web.Model;
internal class RegisterData : UserRegister {
public string RepeatedPassword { get; set; }
public bool PasswordsMatch => Password == RepeatedPassword;
public bool PasswordIsValid => Password?.Length >= 8;
public bool EmailIsValid => Email?.Contains('@') == true && Email?.Contains('.') == true && Email?.EndsWith('.') == false;
}

View File

@@ -0,0 +1,5 @@
namespace HopFrame.Web.Model;
internal sealed class UserAdd : RegisterData {
public string Group { get; set; }
}

View File

@@ -0,0 +1,33 @@
@page "/administration"
@rendermode InteractiveServer
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using BlazorStrap
@using HopFrame.Web.Pages.Administration.Layout
@using BlazorStrap.V5
@using HopFrame.Web.Components
@using Microsoft.AspNetCore.Components.Web
@layout AdminLayout
<PageTitle>Admin Dashboard</PageTitle>
<BSContainer>
<BSRow Justify="Justify.Center">
@foreach (var view in AdminMenu.Subpages) {
<AuthorizedView Permission="@view.Permission">
<BSCol Column="4" style="margin-bottom: 10px">
<BSCard CardType="CardType.Card" Color="BSColor.Dark" style="min-height: 200px">
<BSCard CardType="CardType.Body" style="display: flex; flex-direction: column">
<BSCard CardType="CardType.Title">@view.Name</BSCard>
<BSCard CardType="CardType.Subtitle"><span style="color: gray">@view.Permission</span></BSCard>
<BSCard CardType="CardType.Text">@view.Description</BSCard>
<BSButton IsOutlined="true" MarginTop="Margins.Auto" style="width: max-content; align-self: center" OnClick="() => Navigator.NavigateTo(view.Url, true)" Color="BSColor.Light">Open</BSButton>
</BSCard>
</BSCard>
</BSCol>
</AuthorizedView>
}
</BSRow>
</BSContainer>
@inject NavigationManager Navigator

View File

@@ -0,0 +1,67 @@
@page "/administration/login"
@layout EmptyLayout
@using BlazorStrap
@using BlazorStrap.V5
@using HopFrame.Security.Models
@using HopFrame.Web.Pages.Administration.Layout
@using HopFrame.Web.Services
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Forms
<PageTitle>Login</PageTitle>
<div class="login-wrapper">
<EditForm Model="UserLogin" OnValidSubmit="Login" FormName="login-form">
<div class="field-wrapper">
<h3>Login</h3>
<div class="mb-3">
<BSLabel>E-Mail address</BSLabel>
<InputText type="email" class="form-control" required @bind-Value="UserLogin.Email"/>
</div>
<div class="mb-3">
<BSLabel>Password</BSLabel>
<InputText type="password" class="form-control" required @bind-Value="UserLogin.Password"/>
</div>
<BSButton Color="BSColor.Primary" IsSubmit="true">Login</BSButton>
@if (_hasError) {
<BSAlert Color="BSColor.Danger" style="margin-top: 16px; margin-bottom: 0">Email or password does not match any account!</BSAlert>
}
</div>
</EditForm>
</div>
@inject IAuthService Auth
@inject NavigationManager Navigator
@code {
[SupplyParameterFromForm]
private UserLogin UserLogin { get; set; }
[SupplyParameterFromQuery(Name = "redirect")]
private string RedirectAfter { get; set; }
private const string DefaultRedirect = "/administration";
private bool _hasError = false;
protected override async Task OnInitializedAsync() {
UserLogin ??= new();
if (await Auth.IsLoggedIn()) {
await Auth.Logout();
}
}
private async Task Login() {
var result = await Auth.Login(UserLogin);
if (!result) {
_hasError = true;
return;
}
Navigator.NavigateTo(string.IsNullOrEmpty(RedirectAfter) ? DefaultRedirect : RedirectAfter, true);
}
}

View File

@@ -0,0 +1,15 @@
.login-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.field-wrapper {
margin-top: 25vh;
min-width: 30vw;
padding: 30px;
border: 2px solid #ced4da;
border-radius: 10px;
position: relative;
}

View File

@@ -0,0 +1,191 @@
@page "/administration/groups"
@rendermode InteractiveServer
@layout AdminLayout
@using System.Globalization
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using BlazorStrap
@using Microsoft.AspNetCore.Components.Web
@using HopFrame.Web.Components
@using HopFrame.Web.Components.Administration
@using BlazorStrap.V5
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Claims
@using HopFrame.Security.Services
@using HopFrame.Web.Pages.Administration.Layout
<PageTitle>Groups</PageTitle>
<AuthorizedView Permission="@Security.AdminPermissions.ViewGroups" RedirectIfUnauthorized="administration/login?redirect=/administration/groups"/>
<GroupAddModal ReloadPage="Reload" @ref="_groupAddModal"/>
<div class="title">
<h3>
Groups administration
<span class="reload" @onclick="Reload">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Reload"/>
</span>
</h3>
<form class="d-flex" role="search" id="search" @onsubmit="Search">
<input class="form-control me-2 input-dark" type="search" placeholder="Search" aria-label="Search" @bind="_searchText">
<BSButton Color="BSColor.Success" IsOutlined="true" type="submit">Search</BSButton>
</form>
<AuthorizedView Permission="@Security.AdminPermissions.AddGroup">
<BSButton IsSubmit="false" Color="BSColor.Success" Target="add-user" OnClick="() => _groupAddModal.ShowAsync()">Add Group</BSButton>
</AuthorizedView>
</div>
<BSTable IsStriped="true" IsHoverable="true" IsDark="true" Color="BSColor.Dark">
<BSTHead>
<BSTR>
<BSTD>
<span class="sorter" @onclick="() => OrderBy(OrderType.Name)">Name</span>
@if (_currentOrder == OrderType.Name) {
<HopIconDisplay Type="_currentOrderDirection == OrderDirection.Desc ? HopIconDisplay.HopIcon.ArrowDown : HopIconDisplay.HopIcon.ArrowUp"/>
}
</BSTD>
<BSTD>Description</BSTD>
<BSTD>Default</BSTD>
<BSTD>
<span class="sorter" @onclick="() => OrderBy(OrderType.Created)">Created</span>
@if (_currentOrder == OrderType.Created) {
<HopIconDisplay Type="_currentOrderDirection == OrderDirection.Desc ? HopIconDisplay.HopIcon.ArrowDown : HopIconDisplay.HopIcon.ArrowUp"/>
}
</BSTD>
@if (_hasEditPrivileges || _hasDeletePrivileges) {
<BSTD>Actions</BSTD>
}
</BSTR>
</BSTHead>
<BSTBody>
@foreach (var group in _groups) {
<BSTR>
<BSTD Class="bold">@group.Name.Replace("group.", "")</BSTD>
<BSTD>@group.Description</BSTD>
<BSTD>
@if (group.IsDefaultGroup) {
<span>Yes</span>
}
else {
<span>No</span>
}
</BSTD>
<BSTD>@group.CreatedAt</BSTD>
@if (_hasEditPrivileges || _hasDeletePrivileges) {
<BSTD>
<BSButtonGroup>
@if (_hasEditPrivileges) {
<BSButton Color="BSColor.Warning" OnClick="() => _groupAddModal.ShowAsync(group)">Edit</BSButton>
}
@if (_hasDeletePrivileges) {
<BSButton Color="BSColor.Danger" OnClick="() => Delete(group)">Delete</BSButton>
}
</BSButtonGroup>
</BSTD>
}
</BSTR>
}
</BSTBody>
</BSTable>
@inject IPermissionService Permissions
@inject ITokenContext Auth
@inject SweetAlertService Alerts
@code {
private IList<PermissionGroup> _groups = new List<PermissionGroup>();
private bool _hasEditPrivileges = false;
private bool _hasDeletePrivileges = false;
private string _searchText;
private OrderType _currentOrder = OrderType.None;
private OrderDirection _currentOrderDirection = OrderDirection.Asc;
private GroupAddModal _groupAddModal;
protected override async Task OnInitializedAsync() {
_groups = await Permissions.GetPermissionGroups();
_hasEditPrivileges = await Permissions.HasPermission(Security.AdminPermissions.EditGroup, Auth.User.Id);
_hasDeletePrivileges = await Permissions.HasPermission(Security.AdminPermissions.DeleteGroup, Auth.User.Id);
}
private async Task Reload() {
_groups = new List<PermissionGroup>();
_groups = await Permissions.GetPermissionGroups();
OrderBy(_currentOrder, false);
StateHasChanged();
}
private async Task Search() {
var groups = await Permissions.GetPermissionGroups();
if (!string.IsNullOrWhiteSpace(_searchText)) {
groups = groups
.Where(group => group.Name.Contains(_searchText) ||
group.Description?.Contains(_searchText) == true ||
group.CreatedAt.ToString(CultureInfo.InvariantCulture).Contains(_searchText) ||
group.Permissions.Any(perm => perm.PermissionName.Contains(_searchText)))
.ToList();
}
_groups = groups;
OrderBy(_currentOrder, false);
}
private void OrderBy(OrderType type, bool changeDir = true) {
if (_currentOrder == type && changeDir) _currentOrderDirection = (OrderDirection)(((byte)_currentOrderDirection + 1) % 2);
if (_currentOrder != type) _currentOrderDirection = OrderDirection.Asc;
if (type == OrderType.Name) {
_groups = _currentOrderDirection == OrderDirection.Asc ? _groups.OrderBy(group => group.Name).ToList() : _groups.OrderByDescending(group => group.Name).ToList();
}
else if (type == OrderType.Created) {
_groups = _currentOrderDirection == OrderDirection.Asc ? _groups.OrderBy(group => group.CreatedAt).ToList() : _groups.OrderByDescending(group => group.CreatedAt).ToList();
}
_currentOrder = type;
}
private async Task Delete(PermissionGroup group) {
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Text = "You won't be able to revert this!",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await Permissions.DeletePermissionGroup(group);
await Reload();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Deleted!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
}
private enum OrderType {
None,
Name,
Created
}
private enum OrderDirection : byte {
Asc = 0,
Desc = 1
}
}

View File

@@ -0,0 +1,26 @@
.title {
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 10px;
}
#search {
margin-left: auto;
}
th, h3 {
user-select: none;
}
h3 {
color: white;
}
.reload, .sorter {
cursor: pointer;
}
.bold {
font-weight: bold;
}

View File

@@ -0,0 +1,23 @@
@using HopFrame.Web.Components
@using BlazorStrap.V5
@inherits LayoutComponentBase
<AuthorizedView Permission="@Security.AdminPermissions.IsAdmin" RedirectIfUnauthorized="administration/login" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<div class="page" style="background-color: #2c3034; position: relative; min-height: 100vh">
<nav>
<AdminMenu/>
</nav>
<main style="padding-top: 60px">
<article class="content px-4">
@Body
<BSCore/>
</article>
</main>
</div>
<script src="_content/CurrieTechnologies.Razor.SweetAlert2/sweetAlert2.min.js"></script>
<script src="_content/BlazorStrap/popper.min.js"></script>

View File

@@ -0,0 +1,85 @@
@rendermode InteractiveServer
@using BlazorStrap
@using BlazorStrap.V5
@using HopFrame.Security.Claims
@using HopFrame.Web.Services
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using HopFrame.Web.Components.Administration
@using HopFrame.Web.Model
@using HopFrame.Web.Components
<BSNavbar Color="BSColor.Dark" IsDark="true" IsFixedTop="true">
<BSContainer Container="Container.Fluid">
<BSNavbarBrand>
HopFrame
</BSNavbarBrand>
<BSCollapse IsInNavbar="true">
<Toggler>
<BSNavbarToggle/>
</Toggler>
<Content>
<BSNav MarginEnd="Margins.Auto" MarginBottom="Margins.Small" Class="mb-lg-0">
<BSNavItem IsActive="IsDashboardActive()" OnClick="NavigateToDashboard">Dashboard</BSNavItem>
@foreach (var nav in Subpages) {
<AuthorizedView Permission="@nav.Permission">
<BSNavItem IsActive="IsNavItemActive(nav.Url)" OnClick="() => Navigate(nav.Url)">@nav.Name</BSNavItem>
</AuthorizedView>
}
</BSNav>
<span style="margin-left: auto; line-height: 100%; color: white">
logged in as @Context?.User.Username
</span>
<BSButton DataId="logout" Size="Size.ExtraSmall" OnClick="Logout" Color="BSColor.Dark">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Logout"/>
</BSButton>
<BSTooltip Placement="Placement.Bottom" Target="logout" ContentAlwaysRendered="false">logout</BSTooltip>
</Content>
</BSCollapse>
</BSContainer>
</BSNavbar>
@inject NavigationManager Navigator
@inject ITokenContext Context
@inject IAuthService Auth
@code {
public static IList<NavigationItem> Subpages = new List<NavigationItem> {
new () {
Name = "Users",
Url = "administration/users",
Description = "On this page you can manage all user accounts.",
Permission = Security.AdminPermissions.ViewUsers
},
new () {
Name = "Groups",
Url = "administration/groups",
Description = "On this page you can view, create, edit and delete permission groups.",
Permission = Security.AdminPermissions.ViewGroups
}
};
private bool IsNavItemActive(string element) {
return Navigator.Uri.Contains(element);
}
private bool IsDashboardActive() {
return Navigator.Uri.TrimEnd('/').EndsWith("administration");
}
private void NavigateToDashboard() {
Navigate("administration");
}
private void Navigate(string url) {
Navigator.NavigateTo(url, true);
}
private void Logout() {
Navigator.NavigateTo("administration/login", true);
}
}

View File

@@ -0,0 +1,9 @@
@using BlazorStrap.V5
@inherits LayoutComponentBase
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
@Body
<BSCore/>
<script src="_content/BlazorStrap/popper.min.js"></script>

View File

@@ -0,0 +1,221 @@
@page "/administration/users"
@rendermode InteractiveServer
@layout AdminLayout
@using System.Globalization
@using BlazorStrap
@using CurrieTechnologies.Razor.SweetAlert2
@using HopFrame.Database.Models
@using HopFrame.Security.Claims
@using HopFrame.Security.Services
@using HopFrame.Web.Pages.Administration.Layout
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web
@using HopFrame.Web.Components
@using BlazorStrap.V5
@using HopFrame.Web.Components.Administration
<PageTitle>Users</PageTitle>
<AuthorizedView Permission="@Security.AdminPermissions.ViewUsers" RedirectIfUnauthorized="administration/login?redirect=/administration/users"/>
<UserAddModal @ref="_userAddModal" ReloadPage="Reload"/>
<UserEditModal @ref="_userEditModal" ReloadPage="Reload"/>
<div class="title">
<h3>
Users administration
<span class="reload" @onclick="Reload">
<HopIconDisplay Type="HopIconDisplay.HopIcon.Reload"/>
</span>
</h3>
<form class="d-flex" role="search" @onsubmit="Search" id="search">
<input class="form-control me-2 input-dark" type="search" placeholder="Search" aria-label="Search" @bind="_searchText">
<BSButton Color="BSColor.Success" IsOutlined="true" type="submit">Search</BSButton>
</form>
<AuthorizedView Permission="@Security.AdminPermissions.AddUser">
<BSButton IsSubmit="false" Color="BSColor.Success" Target="add-user" OnClick="() => _userAddModal.ShowAsync()">Add User</BSButton>
</AuthorizedView>
</div>
<BSTable IsStriped="true" IsHoverable="true" IsDark="true" Color="BSColor.Dark">
<BSTHead>
<BSTR>
<BSTD>#</BSTD>
<BSTD>
<span class="sorter" @onclick="() => OrderBy(OrderType.Email)">E-Mail</span>
@if (_currentOrder == OrderType.Email) {
<HopIconDisplay Type="_currentOrderDirection == OrderDirection.Desc ? HopIconDisplay.HopIcon.ArrowDown : HopIconDisplay.HopIcon.ArrowUp"/>
}
</BSTD>
<BSTD>
<span class="sorter" @onclick="() => OrderBy(OrderType.Username)">Username</span>
@if (_currentOrder == OrderType.Username) {
<HopIconDisplay Type="_currentOrderDirection == OrderDirection.Desc ? HopIconDisplay.HopIcon.ArrowDown : HopIconDisplay.HopIcon.ArrowUp"/>
}
</BSTD>
<BSTD>
<span class="sorter" @onclick="() => OrderBy(OrderType.Registered)">Registered</span>
@if (_currentOrder == OrderType.Registered) {
<HopIconDisplay Type="_currentOrderDirection == OrderDirection.Desc ? HopIconDisplay.HopIcon.ArrowDown : HopIconDisplay.HopIcon.ArrowUp"/>
}
</BSTD>
<BSTD>Primary Group</BSTD>
@if (_hasEditPrivileges || _hasDeletePrivileges) {
<BSTD>Actions</BSTD>
}
</BSTR>
</BSTHead>
<BSTBody>
@foreach (var user in _users) {
<BSTR>
<BSTD class="bold">@user.Id</BSTD>
<BSTD>@user.Email</BSTD>
<BSTD>@user.Username</BSTD>
<BSTD>@user.CreatedAt</BSTD>
<BSTD>@GetFriendlyGroupName(user)</BSTD>
@if (_hasEditPrivileges || _hasDeletePrivileges) {
<BSTD>
<BSButtonGroup>
@if (_hasEditPrivileges) {
<BSButton Color="BSColor.Warning" OnClick="() => _userEditModal.ShowAsync(user)">Edit</BSButton>
}
@if (_hasDeletePrivileges) {
<BSButton Color="BSColor.Danger" OnClick="() => Delete(user)">Delete</BSButton>
}
</BSButtonGroup>
</BSTD>
}
</BSTR>
}
</BSTBody>
</BSTable>
@inject IUserService UserService
@inject IPermissionService PermissionsService
@inject SweetAlertService Alerts
@inject ITokenContext Auth
@code {
private IList<User> _users = new List<User>();
private IDictionary<Guid, PermissionGroup> _userGroups = new Dictionary<Guid, PermissionGroup>();
private OrderType _currentOrder = OrderType.None;
private OrderDirection _currentOrderDirection = OrderDirection.Asc;
private string _searchText;
private bool _hasEditPrivileges = false;
private bool _hasDeletePrivileges = false;
private UserAddModal _userAddModal;
private UserEditModal _userEditModal;
protected override async Task OnInitializedAsync() {
_users = await UserService.GetUsers();
foreach (var user in _users) {
var groups = await PermissionsService.GetUserPermissionGroups(user);
_userGroups.Add(user.Id, groups.LastOrDefault());
}
_hasEditPrivileges = await PermissionsService.HasPermission(Security.AdminPermissions.EditUser, Auth.User.Id);
_hasDeletePrivileges = await PermissionsService.HasPermission(Security.AdminPermissions.DeleteUser, Auth.User.Id);
}
private async Task Reload() {
_users = new List<User>();
_userGroups = new Dictionary<Guid, PermissionGroup>();
_users = await UserService.GetUsers();
foreach (var user in _users) {
var groups = await PermissionsService.GetUserPermissionGroups(user);
_userGroups.Add(user.Id, groups.LastOrDefault());
}
OrderBy(_currentOrder, false);
StateHasChanged();
}
private async Task Search() {
var users = await UserService.GetUsers();
if (!string.IsNullOrWhiteSpace(_searchText)) {
users = users
.Where(user =>
user.Email.Contains(_searchText) ||
user.Username.Contains(_searchText) ||
user.Id.ToString().Contains(_searchText) ||
user.CreatedAt.ToString(CultureInfo.InvariantCulture).Contains(_searchText) ||
_userGroups[user.Id]?.Name.Contains(_searchText) == true)
.ToList();
}
_users = users;
OrderBy(_currentOrder, false);
}
private string GetFriendlyGroupName(User user) {
var group = _userGroups[user.Id];
if (group is null) return null;
return group.Name.Replace("group.", "");
}
private void OrderBy(OrderType type, bool changeDir = true) {
if (_currentOrder == type && changeDir) _currentOrderDirection = (OrderDirection)(((byte)_currentOrderDirection + 1) % 2);
if (_currentOrder != type) _currentOrderDirection = OrderDirection.Asc;
if (type == OrderType.Email) {
_users = _currentOrderDirection == OrderDirection.Asc ? _users.OrderBy(user => user.Email).ToList() : _users.OrderByDescending(user => user.Email).ToList();
}
else if (type == OrderType.Username) {
_users = _currentOrderDirection == OrderDirection.Asc ? _users.OrderBy(user => user.Username).ToList() : _users.OrderByDescending(user => user.Username).ToList();
}
else if (type == OrderType.Registered) {
_users = _currentOrderDirection == OrderDirection.Asc ? _users.OrderBy(user => user.CreatedAt).ToList() : _users.OrderByDescending(user => user.CreatedAt).ToList();
}
_currentOrder = type;
}
private async Task Delete(User user) {
var result = await Alerts.FireAsync(new SweetAlertOptions {
Title = "Are you sure?",
Text = "You won't be able to revert this!",
Icon = SweetAlertIcon.Warning,
ConfirmButtonText = "Yes",
ShowCancelButton = true,
ShowConfirmButton = true
});
if (result.IsConfirmed) {
await UserService.DeleteUser(user);
await Reload();
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Deleted!",
Icon = SweetAlertIcon.Success,
Timer = 1500,
ShowConfirmButton = false
});
}
}
private enum OrderType {
None,
Email,
Username,
Registered
}
private enum OrderDirection : byte {
Asc = 0,
Desc = 1
}
}

View File

@@ -0,0 +1,26 @@
.title {
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 10px;
}
#search {
margin-left: auto;
}
th, h3 {
user-select: none;
}
h3 {
color: white;
}
.reload, .sorter {
cursor: pointer;
}
.bold {
font-weight: bold;
}

View File

@@ -0,0 +1,43 @@
# HopFrame Web module
This module contains useful helpers for Blazor Apps and an Admin Dashboard.
## How to use the Blazor API
1. Add the HopFrame.Web library to your project
```
dotnet add package HopFrame.Web
```
2. Create a DbContext that inherits the ``HopDbContext`` and add a data source
```csharp
public class DatabaseContext : HopDbContextBase {
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlite("...");
}
}
```
3. Add the DbContext and HopFrame to your services
```csharp
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>();
```
4. Add the authentication middleware to your app
```csharp
app.UseMiddleware<AuthMiddleware>();
```
5. Add the HopFrame pages to your Razor components
```csharp
app.MapRazorComponents<App>()
.AddHopFrameAdminPages()
.AddInteractiveServerRenderMode();
```

View File

@@ -0,0 +1,33 @@
using BlazorStrap;
using CurrieTechnologies.Razor.SweetAlert2;
using HopFrame.Database;
using HopFrame.Security.Authentication;
using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web;
public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrame<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase {
services.AddHttpClient();
services.AddScoped<IAuthService, AuthService<TDbContext>>();
services.AddTransient<AuthMiddleware>();
// Component library's
services.AddSweetAlert2();
services.AddBlazorStrap();
services.AddHopFrameAuthentication<TDbContext>();
return services;
}
public static RazorComponentsEndpointConventionBuilder AddHopFrameAdminPages(this RazorComponentsEndpointConventionBuilder builder) {
return builder
.DisableAntiforgery()
.AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly)
.AddInteractiveServerRenderMode();
}
}

View File

@@ -0,0 +1,13 @@
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Models;
namespace HopFrame.Web.Services;
public interface IAuthService {
Task Register(UserRegister register);
Task<bool> Login(UserLogin login);
Task Logout();
Task<TokenEntry> RefreshLogin();
Task<bool> IsLoggedIn();
}

View File

@@ -0,0 +1,167 @@
using HopFrame.Database;
using HopFrame.Database.Models.Entries;
using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using HopFrame.Security.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Web.Services.Implementation;
internal class AuthService<TDbContext>(
IUserService userService,
IHttpContextAccessor httpAccessor,
TDbContext context)
: IAuthService where TDbContext : HopDbContextBase {
public async Task Register(UserRegister register) {
var user = await userService.AddUser(register);
if (user is null) return;
var refreshToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.RefreshTokenType,
UserId = user.Id.ToString()
};
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = user.Id.ToString()
};
context.Tokens.AddRange(refreshToken, accessToken);
await context.SaveChangesAsync();
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<HopDbContextBase>.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime,
HttpOnly = false,
Secure = true
});
}
public async Task<bool> Login(UserLogin login) {
var user = await userService.GetUserByEmail(login.Email);
if (user == null) return false;
if (await userService.CheckUserPassword(user, login.Password) == false) return false;
var refreshToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.RefreshTokenType,
UserId = user.Id.ToString()
};
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = user.Id.ToString()
};
context.Tokens.AddRange(refreshToken, accessToken);
await context.SaveChangesAsync();
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<HopDbContextBase>.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime,
HttpOnly = false,
Secure = true
});
return true;
}
public async Task Logout() {
var accessToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType];
var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
var tokenEntries = await context.Tokens.Where(token =>
(token.Token == accessToken && token.Type == TokenEntry.AccessTokenType) ||
(token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType))
.ToArrayAsync();
context.Tokens.Remove(tokenEntries[0]);
context.Tokens.Remove(tokenEntries[1]);
await context.SaveChangesAsync();
httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType);
}
public async Task<TokenEntry> RefreshLogin() {
var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType);
if (token is null) return null;
var oldAccessTokens = context.Tokens
.AsEnumerable()
.Where(old =>
old.Type == TokenEntry.AccessTokenType &&
old.UserId == token.UserId &&
old.CreatedAt + HopFrameAuthentication<TDbContext>.AccessTokenTime < DateTime.Now)
.ToList();
if (oldAccessTokens.Count != 0)
context.Tokens.RemoveRange(oldAccessTokens);
var oldRefreshTokens = context.Tokens
.AsEnumerable()
.Where(old =>
old.Type == TokenEntry.RefreshTokenType &&
old.UserId == token.UserId &&
old.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now)
.ToList();
if (oldRefreshTokens.Count != 0)
context.Tokens.RemoveRange(oldRefreshTokens);
await context.SaveChangesAsync();
if (token.CreatedAt + HopFrameAuthentication<TDbContext>.RefreshTokenTime < DateTime.Now) return null;
var accessToken = new TokenEntry {
CreatedAt = DateTime.Now,
Token = Guid.NewGuid().ToString(),
Type = TokenEntry.AccessTokenType,
UserId = token.UserId
};
await context.Tokens.AddAsync(accessToken);
await context.SaveChangesAsync();
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions {
MaxAge = HopFrameAuthentication<TDbContext>.AccessTokenTime,
HttpOnly = false,
Secure = true
});
return accessToken;
}
public async Task<bool> IsLoggedIn() {
var accessToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType];
if (string.IsNullOrEmpty(accessToken)) return false;
var tokenEntry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == accessToken);
if (tokenEntry is null) return false;
if (tokenEntry.CreatedAt + HopFrameAuthentication<TDbContext>.AccessTokenTime < DateTime.Now) return false;
if (!await context.Users.AnyAsync(user => user.Id == tokenEntry.UserId)) return false;
return true;
}
}