diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a682c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +/packages/ +riderModule.iml diff --git a/.idea/.idea.HopFrame/.idea/.gitignore b/.idea/.idea.HopFrame/.idea/.gitignore new file mode 100644 index 0000000..4806007 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.HopFrame.iml +/projectSettingsUpdater.xml +/modules.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.HopFrame/.idea/dataSources.xml b/.idea/.idea.HopFrame/.idea/dataSources.xml new file mode 100644 index 0000000..56f170e --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/dataSources.xml @@ -0,0 +1,15 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/RestApiTest/bin/Debug/net8.0/test.db + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/.idea.HopFrame/.idea/indexLayout.xml b/.idea/.idea.HopFrame/.idea/indexLayout.xml new file mode 100644 index 0000000..db94204 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/indexLayout.xml @@ -0,0 +1,10 @@ + + + + + docs + + + + + \ No newline at end of file diff --git a/.idea/.idea.HopFrame/.idea/vcs.xml b/.idea/.idea.HopFrame/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.HopFrame/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/FrontendTest/.gitignore b/FrontendTest/.gitignore new file mode 100644 index 0000000..ab7b4dc --- /dev/null +++ b/FrontendTest/.gitignore @@ -0,0 +1,4 @@ +obj +bin +Migrations +appsettings.Development.json diff --git a/FrontendTest/Components/App.razor b/FrontendTest/Components/App.razor new file mode 100644 index 0000000..35c8065 --- /dev/null +++ b/FrontendTest/Components/App.razor @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/FrontendTest/Components/Layout/MainLayout.razor b/FrontendTest/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..df0c2d6 --- /dev/null +++ b/FrontendTest/Components/Layout/MainLayout.razor @@ -0,0 +1,23 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
\ No newline at end of file diff --git a/FrontendTest/Components/Layout/MainLayout.razor.css b/FrontendTest/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..038baf1 --- /dev/null +++ b/FrontendTest/Components/Layout/MainLayout.razor.css @@ -0,0 +1,96 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/FrontendTest/Components/Layout/NavMenu.razor b/FrontendTest/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..69622af --- /dev/null +++ b/FrontendTest/Components/Layout/NavMenu.razor @@ -0,0 +1,29 @@ + + + + + \ No newline at end of file diff --git a/FrontendTest/Components/Layout/NavMenu.razor.css b/FrontendTest/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000..4e15395 --- /dev/null +++ b/FrontendTest/Components/Layout/NavMenu.razor.css @@ -0,0 +1,105 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep .nav-link { + color: #d7d7d7; + background: none; + border: none; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + width: 100%; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep .nav-link:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/FrontendTest/Components/Pages/Counter.razor b/FrontendTest/Components/Pages/Counter.razor new file mode 100644 index 0000000..4fdbec5 --- /dev/null +++ b/FrontendTest/Components/Pages/Counter.razor @@ -0,0 +1,20 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + private string[] permissions = ["web.counter"]; + + private void IncrementCount() { + currentCount++; + } + +} \ No newline at end of file diff --git a/FrontendTest/Components/Pages/Error.razor b/FrontendTest/Components/Pages/Error.razor new file mode 100644 index 0000000..06de831 --- /dev/null +++ b/FrontendTest/Components/Pages/Error.razor @@ -0,0 +1,35 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) { +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + +} \ No newline at end of file diff --git a/FrontendTest/Components/Pages/Home.razor b/FrontendTest/Components/Pages/Home.razor new file mode 100644 index 0000000..b6f408e --- /dev/null +++ b/FrontendTest/Components/Pages/Home.razor @@ -0,0 +1,13 @@ +@page "/" +@using HopFrame.Security.Claims +@using HopFrame.Web.Components + + + +Home + +

Hello, world!

+ +Welcome to your new app. @Context.User?.Username + +@inject ITokenContext Context diff --git a/FrontendTest/Components/Pages/Weather.razor b/FrontendTest/Components/Pages/Weather.razor new file mode 100644 index 0000000..b373790 --- /dev/null +++ b/FrontendTest/Components/Pages/Weather.razor @@ -0,0 +1,61 @@ +@page "/weather" +@attribute [StreamRendering] + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ +@if (forecasts == null) { +

+ Loading... +

+} +else { + + + + + + + + + + + @foreach (var forecast in forecasts) { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private class WeatherForecast { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } + +} \ No newline at end of file diff --git a/FrontendTest/Components/Routes.razor b/FrontendTest/Components/Routes.razor new file mode 100644 index 0000000..ae94e9e --- /dev/null +++ b/FrontendTest/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/FrontendTest/Components/_Imports.razor b/FrontendTest/Components/_Imports.razor new file mode 100644 index 0000000..b17e0c0 --- /dev/null +++ b/FrontendTest/Components/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using FrontendTest +@using FrontendTest.Components \ No newline at end of file diff --git a/FrontendTest/DatabaseContext.cs b/FrontendTest/DatabaseContext.cs new file mode 100644 index 0000000..20945c9 --- /dev/null +++ b/FrontendTest/DatabaseContext.cs @@ -0,0 +1,12 @@ +using HopFrame.Database; +using Microsoft.EntityFrameworkCore; + +namespace FrontendTest; + +public class DatabaseContext : HopDbContextBase { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\RestApiTest\\bin\\Debug\\net8.0\\test.db;Mode=ReadWrite;"); + } +} \ No newline at end of file diff --git a/FrontendTest/FrontendTest.csproj b/FrontendTest/FrontendTest.csproj new file mode 100644 index 0000000..79d6740 --- /dev/null +++ b/FrontendTest/FrontendTest.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" /> + <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" /> + + + diff --git a/FrontendTest/Program.cs b/FrontendTest/Program.cs new file mode 100644 index 0000000..20102b4 --- /dev/null +++ b/FrontendTest/Program.cs @@ -0,0 +1,35 @@ +using FrontendTest; +using FrontendTest.Components; +using HopFrame.Web; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(); +builder.Services.AddHopFrameServices(); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseStaticFiles(); +app.UseAntiforgery(); +app.UseAuthorization(); +app.UseAuthentication(); +app.UseMiddleware(); + +app.MapRazorComponents() + .AddHopFrameAdminPages() + .AddInteractiveServerRenderMode(); + +app.Run(); \ No newline at end of file diff --git a/FrontendTest/Properties/launchSettings.json b/FrontendTest/Properties/launchSettings.json new file mode 100644 index 0000000..c5e7ff4 --- /dev/null +++ b/FrontendTest/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:65174", + "sslPort": 44387 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5007", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7049;http://localhost:5007", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/FrontendTest/appsettings.Development.json b/FrontendTest/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/FrontendTest/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/FrontendTest/appsettings.json b/FrontendTest/appsettings.json new file mode 100644 index 0000000..470eccb --- /dev/null +++ b/FrontendTest/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "HopFrame.Security.Authentication.HopFrameAuthentication": "None" + } + }, + "AllowedHosts": "*" +} diff --git a/FrontendTest/wwwroot/app.css b/FrontendTest/wwwroot/app.css new file mode 100644 index 0000000..2bd9b78 --- /dev/null +++ b/FrontendTest/wwwroot/app.css @@ -0,0 +1,51 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} diff --git a/FrontendTest/wwwroot/favicon.png b/FrontendTest/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/FrontendTest/wwwroot/favicon.png differ diff --git a/HopFrame.Api/Controller/SecurityController.cs b/HopFrame.Api/Controller/SecurityController.cs new file mode 100644 index 0000000..022db42 --- /dev/null +++ b/HopFrame.Api/Controller/SecurityController.cs @@ -0,0 +1,170 @@ +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 context, IUserService users, ITokenContext tokenContext) : ControllerBase where TDbContext : HopDbContextBase { + + [HttpPut("login")] + public async Task>> Login([FromBody] UserLogin login) { + var user = await users.GetUserByEmail(login.Email); + + if (user is null) + return LogicResult>.NotFound("The provided email address was not found"); + + if (await users.CheckUserPassword(user, login.Password)) + return LogicResult>.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.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + + await context.Tokens.AddRangeAsync(refreshToken, accessToken); + await context.SaveChangesAsync(); + + return LogicResult>.Ok(accessToken.Token); + } + + [HttpPost("register")] + public async Task>> Register([FromBody] UserRegister register) { + if (register.Password.Length < 8) + return LogicResult>.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>.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.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, + HttpOnly = false, + Secure = true + }); + + return LogicResult>.Ok(accessToken.Token); + } + + [HttpGet("authenticate")] + public async Task>> Authenticate() { + var refreshToken = HttpContext.Request.Cookies[ITokenContext.RefreshTokenType]; + + if (string.IsNullOrEmpty(refreshToken)) + return LogicResult>.Conflict("Refresh token not provided"); + + var token = await context.Tokens.SingleOrDefaultAsync(token => token.Token == refreshToken && token.Type == TokenEntry.RefreshTokenType); + + if (token is null) + return LogicResult>.NotFound("Refresh token not valid"); + + if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) + return LogicResult>.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.AccessTokenTime, + HttpOnly = false, + Secure = true + }); + + return LogicResult>.Ok(accessToken.Token); + } + + [HttpDelete("logout"), Authorized] + public async Task 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 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); + + return LogicResult.Ok(); + } + +} \ No newline at end of file diff --git a/HopFrame.Api/Extensions/MvcExtensions.cs b/HopFrame.Api/Extensions/MvcExtensions.cs new file mode 100644 index 0000000..d176de7 --- /dev/null +++ b/HopFrame.Api/Extensions/MvcExtensions.cs @@ -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 { + /// + /// Finds the appropriate controllers + /// + /// The manager for the parts + /// The controller types that are allowed. + public static void UseSpecificControllers(this ApplicationPartManager partManager, params Type[] controllerTypes) { + partManager.FeatureProviders.Add(new InternalControllerFeatureProvider()); + //partManager.ApplicationParts.Clear(); + partManager.ApplicationParts.Add(new SelectedControllersApplicationParts(controllerTypes)); + } + + /// + /// Only allow selected controllers + /// + /// The builder that configures mvc core + /// The controller types that are allowed. + public static IMvcCoreBuilder + UseSpecificControllers(this IMvcCoreBuilder mvcCoreBuilder, params Type[] controllerTypes) => + mvcCoreBuilder.ConfigureApplicationPartManager(partManager => + partManager.UseSpecificControllers(controllerTypes)); + + /// + /// Only instantiates selected controllers, not all of them. Prevents application scanning for controllers. + /// + 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 Types { get; } + } + + /// + /// Ensure that internal controllers are also allowed. The default ControllerFeatureProvider hides internal controllers, but this one allows it. + /// + private class InternalControllerFeatureProvider : ControllerFeatureProvider { + private const string ControllerTypeNameSuffix = "Controller"; + + /// + /// Determines if a given is a controller. The default ControllerFeatureProvider hides internal controllers, but this one allows it. + /// + /// The candidate. + /// true if the type is a controller; otherwise false. + 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; + } + } +} \ No newline at end of file diff --git a/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..568580e --- /dev/null +++ b/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -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 { + + /// + /// Adds all HopFrame endpoints and the HopFrame security layer to the WebApplication + /// + /// The service provider to add the services to + /// The data source for all HopFrame entities + public static void AddHopFrame(this IServiceCollection services) where TDbContext : HopDbContextBase { + services.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); + services.AddHopFrameAuthentication(); + } + +} diff --git a/HopFrame.Api/HopFrame.Api.csproj b/HopFrame.Api/HopFrame.Api.csproj new file mode 100644 index 0000000..fa215ac --- /dev/null +++ b/HopFrame.Api/HopFrame.Api.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + latest + enable + disable + README.md + MIT + + + + + + + + + + + + diff --git a/HopFrame.Api/Logic/ILogicResult.cs b/HopFrame.Api/Logic/ILogicResult.cs new file mode 100644 index 0000000..5efb2aa --- /dev/null +++ b/HopFrame.Api/Logic/ILogicResult.cs @@ -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 { + HttpStatusCode State { get; set; } + + T Data { get; set; } + + string Message { get; set; } + + bool IsSuccessful { get; } +} \ No newline at end of file diff --git a/HopFrame.Api/Logic/LogicResult.cs b/HopFrame.Api/Logic/LogicResult.cs new file mode 100644 index 0000000..0eb6879 --- /dev/null +++ b/HopFrame.Api/Logic/LogicResult.cs @@ -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(ILogicResult 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 : ILogicResult { + public HttpStatusCode State { get; set; } + + public T Data { 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 Ok(T result) { + return new LogicResult() { + State = HttpStatusCode.OK, + Data = result + }; + } + + 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(ILogicResult result) { + return new LogicResult() { + State = result.State, + Message = result.Message + }; + } + + public static LogicResult Forward(ILogicResult result) { + return new LogicResult() { + State = result.State, + Message = result.Message + }; + } + + public static implicit operator ActionResult(LogicResult v) { + if (v.State == HttpStatusCode.OK) return new OkObjectResult(v.Data); + + return new ObjectResult(v.Message) { + StatusCode = (int)v.State + }; + } +} \ No newline at end of file diff --git a/HopFrame.Api/Models/SingleValueResult.cs b/HopFrame.Api/Models/SingleValueResult.cs new file mode 100644 index 0000000..c09fdb6 --- /dev/null +++ b/HopFrame.Api/Models/SingleValueResult.cs @@ -0,0 +1,13 @@ +namespace HopFrame.Api.Models; + +public struct SingleValueResult(TValue value) { + public TValue Value { get; set; } = value; + + public static implicit operator TValue(SingleValueResult v) { + return v.Value; + } + + public static implicit operator SingleValueResult(TValue v) { + return new SingleValueResult(v); + } +} \ No newline at end of file diff --git a/HopFrame.Api/Models/UserPasswordValidation.cs b/HopFrame.Api/Models/UserPasswordValidation.cs new file mode 100644 index 0000000..06ac627 --- /dev/null +++ b/HopFrame.Api/Models/UserPasswordValidation.cs @@ -0,0 +1,5 @@ +namespace HopFrame.Api.Models; + +public sealed class UserPasswordValidation { + public string Password { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Api/README.md b/HopFrame.Api/README.md new file mode 100644 index 0000000..2a42044 --- /dev/null +++ b/HopFrame.Api/README.md @@ -0,0 +1,2 @@ +# HopFrame API module +This module contains some useful endpoints for user login / register management. diff --git a/HopFrame.Database/HopDbContextBase.cs b/HopFrame.Database/HopDbContextBase.cs new file mode 100644 index 0000000..2ae1fe6 --- /dev/null +++ b/HopFrame.Database/HopDbContextBase.cs @@ -0,0 +1,32 @@ +using HopFrame.Database.Models.Entries; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Database; + +/// +/// This class includes the basic database structure in order for HopFrame to work +/// +public abstract class HopDbContextBase : DbContext { + + public virtual DbSet Users { get; set; } + public virtual DbSet Permissions { get; set; } + public virtual DbSet Tokens { get; set; } + public virtual DbSet Groups { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } + + /// + /// 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 + /// + /// + public virtual void OnUserDelete(UserEntry user) {} +} \ No newline at end of file diff --git a/HopFrame.Database/HopFrame.Database.csproj b/HopFrame.Database/HopFrame.Database.csproj new file mode 100644 index 0000000..870f818 --- /dev/null +++ b/HopFrame.Database/HopFrame.Database.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + latest + enable + disable + README.md + MIT + + + + + + + + + + + diff --git a/HopFrame.Database/Models/Entries/GroupEntry.cs b/HopFrame.Database/Models/Entries/GroupEntry.cs new file mode 100644 index 0000000..830d466 --- /dev/null +++ b/HopFrame.Database/Models/Entries/GroupEntry.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace HopFrame.Database.Models.Entries; + +public class GroupEntry { + [Key, Required, MaxLength(50)] + public string Name { get; set; } + + [Required, DefaultValue(false)] + public bool Default { get; set; } + + [MaxLength(500)] + public string Description { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/Entries/PermissionEntry.cs b/HopFrame.Database/Models/Entries/PermissionEntry.cs new file mode 100644 index 0000000..2f8bdae --- /dev/null +++ b/HopFrame.Database/Models/Entries/PermissionEntry.cs @@ -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; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/Entries/TokenEntry.cs b/HopFrame.Database/Models/Entries/TokenEntry.cs new file mode 100644 index 0000000..d33b307 --- /dev/null +++ b/HopFrame.Database/Models/Entries/TokenEntry.cs @@ -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; + + /// + /// Defines the Type of the stored Token + /// 0: Refresh token + /// 1: Access token + /// + [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; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/Entries/UserEntry.cs b/HopFrame.Database/Models/Entries/UserEntry.cs new file mode 100644 index 0000000..2bc1a12 --- /dev/null +++ b/HopFrame.Database/Models/Entries/UserEntry.cs @@ -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; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/ModelExtensions.cs b/HopFrame.Database/Models/ModelExtensions.cs new file mode 100644 index 0000000..4600afd --- /dev/null +++ b/HopFrame.Database/Models/ModelExtensions.cs @@ -0,0 +1,56 @@ +using HopFrame.Database.Models.Entries; + +namespace HopFrame.Database.Models; + +public static class ModelExtensions { + + /// + /// Converts the database model to a friendly user model + /// + /// the database model + /// the data source for the permissions and users + /// + 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; + } + +} \ No newline at end of file diff --git a/HopFrame.Database/Models/Permission.cs b/HopFrame.Database/Models/Permission.cs new file mode 100644 index 0000000..e6fbe14 --- /dev/null +++ b/HopFrame.Database/Models/Permission.cs @@ -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 {} diff --git a/HopFrame.Database/Models/PermissionGroup.cs b/HopFrame.Database/Models/PermissionGroup.cs new file mode 100644 index 0000000..3472e39 --- /dev/null +++ b/HopFrame.Database/Models/PermissionGroup.cs @@ -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 Permissions { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/Models/User.cs b/HopFrame.Database/Models/User.cs new file mode 100644 index 0000000..e97d720 --- /dev/null +++ b/HopFrame.Database/Models/User.cs @@ -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 Permissions { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Database/README.md b/HopFrame.Database/README.md new file mode 100644 index 0000000..0aacfa2 --- /dev/null +++ b/HopFrame.Database/README.md @@ -0,0 +1,2 @@ +# HopFrame Database module +This module contains all the logic for the database communication diff --git a/HopFrame.Security/Authentication/HopFrameAuthentication.cs b/HopFrame.Security/Authentication/HopFrameAuthentication.cs new file mode 100644 index 0000000..e688096 --- /dev/null +++ b/HopFrame.Security/Authentication/HopFrameAuthentication.cs @@ -0,0 +1,55 @@ +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( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + TDbContext context, + IPermissionService perms) + : AuthenticationHandler(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 HandleAuthenticateAsync() { + var accessToken = Request.Cookies[ITokenContext.AccessTokenType]; + 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 { + 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)); + } + +} \ No newline at end of file diff --git a/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs new file mode 100644 index 0000000..f604c34 --- /dev/null +++ b/HopFrame.Security/Authentication/HopFrameAuthenticationExtensions.cs @@ -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 { + + /// + /// Configures the WebApplication to use the authentication and authorization of the HopFrame API + /// + /// The service provider to add the services to + /// The database object that saves all entities that are important for the security api + /// + public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service) where TDbContext : HopDbContextBase { + service.TryAddSingleton(); + service.AddScoped>(); + service.AddScoped>(); + service.AddScoped>(); + + service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme>(HopFrameAuthentication.SchemeName, _ => {}); + service.AddAuthorization(); + + return service; + } + +} \ No newline at end of file diff --git a/HopFrame.Security/Authorization/AuthorizedAttribute.cs b/HopFrame.Security/Authorization/AuthorizedAttribute.cs new file mode 100644 index 0000000..436881d --- /dev/null +++ b/HopFrame.Security/Authorization/AuthorizedAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace HopFrame.Security.Authorization; + +public class AuthorizedAttribute : TypeFilterAttribute { + + /// + /// If this decorator is present, the endpoint is only accessible if the user provided a valid access token (is logged in) + /// permission system:
+ /// - "*" -> all rights
+ /// - "group.[name]" -> group member
+ /// - "[namespace].[name]" -> single permission
+ /// - "[namespace].*" -> all permissions in the namespace + ///
+ /// specifies the permissions the user needs to have in order to access this endpoint + public AuthorizedAttribute(params string[] permissions) : base(typeof(AuthorizedFilter)) { + Arguments = [permissions]; + } +} \ No newline at end of file diff --git a/HopFrame.Security/Authorization/AuthorizedFilter.cs b/HopFrame.Security/Authorization/AuthorizedFilter.cs new file mode 100644 index 0000000..13f5932 --- /dev/null +++ b/HopFrame.Security/Authorization/AuthorizedFilter.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/HopFrame.Security/Authorization/PermissionValidator.cs b/HopFrame.Security/Authorization/PermissionValidator.cs new file mode 100644 index 0000000..d4280b9 --- /dev/null +++ b/HopFrame.Security/Authorization/PermissionValidator.cs @@ -0,0 +1,21 @@ +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 == "*")) return true; + + foreach (var perm in permsLow) { + if (!perm.EndsWith(".*")) continue; + + var permissionGroup = perm.Replace(".*", ""); + if (permLow.StartsWith(permissionGroup)) return true; + } + + return false; + } + +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/HopFrameClaimTypes.cs b/HopFrame.Security/Claims/HopFrameClaimTypes.cs new file mode 100644 index 0000000..595f76c --- /dev/null +++ b/HopFrame.Security/Claims/HopFrameClaimTypes.cs @@ -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(); + } +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/ITokenContext.cs b/HopFrame.Security/Claims/ITokenContext.cs new file mode 100644 index 0000000..3b4f5e9 --- /dev/null +++ b/HopFrame.Security/Claims/ITokenContext.cs @@ -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"; + + /// + /// This field specifies that a valid user is accessing the endpoint + /// + bool IsAuthenticated { get; } + + /// + /// The user that is accessing the endpoint + /// + User User { get; } + + /// + /// The access token the user provided + /// + Guid AccessToken { get; } +} \ No newline at end of file diff --git a/HopFrame.Security/Claims/TokenContextImplementor.cs b/HopFrame.Security/Claims/TokenContextImplementor.cs new file mode 100644 index 0000000..dbdae9e --- /dev/null +++ b/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -0,0 +1,15 @@ +using HopFrame.Database; +using HopFrame.Database.Models; +using Microsoft.AspNetCore.Http; + +namespace HopFrame.Security.Claims; + +internal sealed class TokenContextImplementor(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()); +} \ No newline at end of file diff --git a/HopFrame.Security/EncryptionManager.cs b/HopFrame.Security/EncryptionManager.cs new file mode 100644 index 0000000..8f5037b --- /dev/null +++ b/HopFrame.Security/EncryptionManager.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + +namespace HopFrame.Security; + +public static class EncryptionManager { + + /// + /// Encrypts the given string with the specified hash method + /// + /// The raw string that should be hashed + /// The "password" for the hash + /// The preferred hash method + /// + 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 + )); + } + +} \ No newline at end of file diff --git a/HopFrame.Security/HopFrame.Security.csproj b/HopFrame.Security/HopFrame.Security.csproj new file mode 100644 index 0000000..8437d1d --- /dev/null +++ b/HopFrame.Security/HopFrame.Security.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + latest + enable + disable + HopFrame.Security + README.md + MIT + + + + + + + + + + + + + + + + diff --git a/HopFrame.Security/Models/UserLogin.cs b/HopFrame.Security/Models/UserLogin.cs new file mode 100644 index 0000000..6faa31b --- /dev/null +++ b/HopFrame.Security/Models/UserLogin.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Security.Models; + +public class UserLogin { + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Security/Models/UserRegister.cs b/HopFrame.Security/Models/UserRegister.cs new file mode 100644 index 0000000..e0ded51 --- /dev/null +++ b/HopFrame.Security/Models/UserRegister.cs @@ -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; } +} \ No newline at end of file diff --git a/HopFrame.Security/README.md b/HopFrame.Security/README.md new file mode 100644 index 0000000..fec1066 --- /dev/null +++ b/HopFrame.Security/README.md @@ -0,0 +1,2 @@ +# HopFrame Security module +this module contains all handlers for the login and register validation. It also checks the user permissions. diff --git a/HopFrame.Security/Services/IPermissionService.cs b/HopFrame.Security/Services/IPermissionService.cs new file mode 100644 index 0000000..2071ce9 --- /dev/null +++ b/HopFrame.Security/Services/IPermissionService.cs @@ -0,0 +1,41 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Security.Services; + +public interface IPermissionService { + + Task HasPermission(string permission, Guid user); + + Task> GetPermissionGroups(); + + Task GetPermissionGroup(string name); + + Task EditPermissionGroup(PermissionGroup group); + + Task> GetUserPermissionGroups(User user); + + Task RemoveGroupFromUser(User user, PermissionGroup group); + + Task CreatePermissionGroup(string name, bool isDefault = false, string description = null); + + Task DeletePermissionGroup(PermissionGroup group); + + Task GetPermission(string name, IPermissionOwner owner); + + /// + /// permission system:
+ /// - "*" -> all rights
+ /// - "group.[name]" -> group member
+ /// - "[namespace].[name]" -> single permission
+ /// - "[namespace].*" -> all permissions in the namespace + ///
+ /// + /// + /// + Task AddPermission(IPermissionOwner owner, string permission); + + Task RemovePermission(Permission permission); + + Task GetFullPermissions(string user); + +} \ No newline at end of file diff --git a/HopFrame.Security/Services/IUserService.cs b/HopFrame.Security/Services/IUserService.cs new file mode 100644 index 0000000..5109dea --- /dev/null +++ b/HopFrame.Security/Services/IUserService.cs @@ -0,0 +1,29 @@ +using HopFrame.Database.Models; +using HopFrame.Security.Models; + +namespace HopFrame.Security.Services; + +public interface IUserService { + Task> GetUsers(); + + Task GetUser(Guid userId); + + Task GetUserByEmail(string email); + + Task GetUserByUsername(string username); + + Task AddUser(UserRegister user); + + /// + /// IMPORTANT:
+ /// This function does not add or remove any permissions to the user. + /// For that please use + ///
+ Task UpdateUser(User user); + + Task DeleteUser(User user); + + Task CheckUserPassword(User user, string password); + + Task ChangePassword(User user, string password); +} \ No newline at end of file diff --git a/HopFrame.Security/Services/Implementation/PermissionService.cs b/HopFrame.Security/Services/Implementation/PermissionService.cs new file mode 100644 index 0000000..ac0e156 --- /dev/null +++ b/HopFrame.Security/Services/Implementation/PermissionService.cs @@ -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 context, ITokenContext current) : IPermissionService where TDbContext : HopDbContextBase { + public async Task HasPermission(string permission) { + return await HasPermission(permission, current.User.Id); + } + + public async Task 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 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 HasPermission(string permission, Guid user) { + var permissions = await GetFullPermissions(user.ToString()); + + return PermissionValidator.IncludesPermission(permission, permissions); + } + + public async Task> GetPermissionGroups() { + return await context.Groups + .Select(group => group.ToPermissionGroup(context)) + .ToListAsync(); + } + + public Task 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> 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 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 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 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(); + foreach (var group in groups) { + var perms = await GetFullPermissions(group); + groupPerms.AddRange(perms); + } + + permissions.AddRange(groupPerms); + + return permissions.ToArray(); + } +} \ No newline at end of file diff --git a/HopFrame.Security/Services/Implementation/UserService.cs b/HopFrame.Security/Services/Implementation/UserService.cs new file mode 100644 index 0000000..0e19b58 --- /dev/null +++ b/HopFrame.Security/Services/Implementation/UserService.cs @@ -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 context) : IUserService where TDbContext : HopDbContextBase { + public async Task> GetUsers() { + return await context.Users + .Select(user => user.ToUserModel(context)) + .ToListAsync(); + } + + public Task GetUser(Guid userId) { + var id = userId.ToString(); + + return context.Users + .Where(user => user.Id == id) + .Select(user => user.ToUserModel(context)) + .SingleOrDefaultAsync(); + } + + public Task GetUserByEmail(string email) { + return context.Users + .Where(user => user.Email == email) + .Select(user => user.ToUserModel(context)) + .SingleOrDefaultAsync(); + } + + public Task GetUserByUsername(string username) { + return context.Users + .Where(user => user.Username == username) + .Select(user => user.ToUserModel(context)) + .SingleOrDefaultAsync(); + } + + public async Task 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 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(); + } +} \ No newline at end of file diff --git a/HopFrame.Web/AdminPermissions.cs b/HopFrame.Web/AdminPermissions.cs new file mode 100644 index 0000000..499cdb0 --- /dev/null +++ b/HopFrame.Web/AdminPermissions.cs @@ -0,0 +1,15 @@ +namespace HopFrame.Web; + +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"; +} \ No newline at end of file diff --git a/HopFrame.Web/AuthMiddleware.cs b/HopFrame.Web/AuthMiddleware.cs new file mode 100644 index 0000000..bc0c0c5 --- /dev/null +++ b/HopFrame.Web/AuthMiddleware.cs @@ -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 { + 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.SchemeName)); + } + + await next?.Invoke(context); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Components/Administration/GroupAddModal.razor b/HopFrame.Web/Components/Administration/GroupAddModal.razor new file mode 100644 index 0000000..95c710b --- /dev/null +++ b/HopFrame.Web/Components/Administration/GroupAddModal.razor @@ -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 + + + + @if (_isEdit) { + Edit group + } + else { + Add group + } + +
+ Name + @if (!_isEdit) { + + group. + + + } + else { + + } +
+ + @if (_isEdit) { +
+ Created at + +
+ } + +
+ Description + +
+ +
+ + Default group + +
+ +
+ Inherits from + + + + @foreach (var group in _group.Permissions.Where(g => g.PermissionName.StartsWith("group."))) { + + + + + + @group.PermissionName.Replace("group.", "") + + } + + + +
+ + + + @foreach (var group in _allGroups) { + @if (_group.Permissions.All(g => g.PermissionName != group.Name) && group.Name != _group.Name) { + + } + } + + Add +
+
+
+
+ +
+ Permissions + + + + @foreach (var perm in _group.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) { + + + + + + @perm.PermissionName + + } + + + +
+ + Add +
+
+
+
+
+ + Cancel + Save + +
+
+ +@inject IPermissionService Permissions +@inject SweetAlertService Alerts +@inject ITokenContext Context + +@code { + [Parameter] public Func ReloadPage { get; set; } + + private PermissionGroupAdd _group; + + private BSModalBase _modal; + private string _permissionToAdd; + private string _groupToAdd; + + private IList _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(), + 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(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(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(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(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 + }); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Components/Administration/HopIconDisplay.razor b/HopFrame.Web/Components/Administration/HopIconDisplay.razor new file mode 100644 index 0000000..48e8c66 --- /dev/null +++ b/HopFrame.Web/Components/Administration/HopIconDisplay.razor @@ -0,0 +1,76 @@ +@switch (Type) { + case HopIcon.Reload: + + + + + break; + + case HopIcon.ArrowUp: + + + + break; + + case HopIcon.ArrowDown: + + + + break; + + case HopIcon.Cross: + + + + break; + + case HopIcon.User: + + + + break; + + case HopIcon.Group: + + + + break; + + case HopIcon.Logout: + + + + + break; +} + + + +@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"; + } +} \ No newline at end of file diff --git a/HopFrame.Web/Components/Administration/UserAddModal.razor b/HopFrame.Web/Components/Administration/UserAddModal.razor new file mode 100644 index 0000000..2a743af --- /dev/null +++ b/HopFrame.Web/Components/Administration/UserAddModal.razor @@ -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 + + + + Add user + +
+ E-Mail + +
+ +
+ Username + +
+ +
+ Password + +
+ +
+ Primary group + + + + @foreach (var group in _allGroups) { + + } + +
+
+ + Cancel + Save + +
+
+ +@inject IUserService Users +@inject IPermissionService Permissions +@inject SweetAlertService Alerts +@inject ITokenContext Auth + +@code { + [Parameter] public Func ReloadPage { get; set; } + + private IList _allGroups = new List(); + private IList _allUsers = new List(); + 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(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 + }); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Components/Administration/UserEditModal.razor b/HopFrame.Web/Components/Administration/UserEditModal.razor new file mode 100644 index 0000000..77f82f6 --- /dev/null +++ b/HopFrame.Web/Components/Administration/UserEditModal.razor @@ -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 + + + + Edit @_user.Username + +
+ User id + +
+
+ Created at + +
+
+ E-Mail + +
+
+ Username + +
+
+ Password + +
+ +
+ Groups + + + + @foreach (var group in _userGroups) { + + + + + + @group.Name.Replace("group.", "") + + } + + + +
+ + + + @foreach (var group in _allGroups) { + @if (_userGroups.All(g => g.Name != group.Name)) { + + } + } + + Add +
+
+
+
+ +
+ Permissions + + + + @foreach (var perm in _user.Permissions.Where(perm => !perm.PermissionName.StartsWith("group."))) { + + + + + + @perm.PermissionName + + } + + + +
+ + Add +
+
+
+
+
+ + Cancel + Save + +
+
+ +@inject IUserService Users +@inject IPermissionService Permissions +@inject SweetAlertService Alerts +@inject ITokenContext Auth + +@code { + [Parameter] public Func ReloadPage { get; set; } + + private BSModalBase _modal; + private User _user; + private string _newPassword; + + private IList _userGroups; + private IList _allGroups; + private string _selectedGroup; + private string _permissionToAdd; + + public async Task ShowAsync(User user) { + if (!(await Permissions.HasPermission(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(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(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(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(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(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 + }); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Components/AuthorizedView.razor b/HopFrame.Web/Components/AuthorizedView.razor new file mode 100644 index 0000000..5c38d4d --- /dev/null +++ b/HopFrame.Web/Components/AuthorizedView.razor @@ -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(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; + } +} \ No newline at end of file diff --git a/HopFrame.Web/HopFrame.Web.csproj b/HopFrame.Web/HopFrame.Web.csproj new file mode 100644 index 0000000..88fe5c7 --- /dev/null +++ b/HopFrame.Web/HopFrame.Web.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + disable + enable + true + README.md + MIT + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HopFrame.Web/Model/NavigationItem.cs b/HopFrame.Web/Model/NavigationItem.cs new file mode 100644 index 0000000..6e255a0 --- /dev/null +++ b/HopFrame.Web/Model/NavigationItem.cs @@ -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; } +} \ No newline at end of file diff --git a/HopFrame.Web/Model/PermissionGroupAdd.cs b/HopFrame.Web/Model/PermissionGroupAdd.cs new file mode 100644 index 0000000..0cdc9d2 --- /dev/null +++ b/HopFrame.Web/Model/PermissionGroupAdd.cs @@ -0,0 +1,7 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Web.Model; + +internal sealed class PermissionGroupAdd : PermissionGroup { + public string GroupName { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Web/Model/RegisterData.cs b/HopFrame.Web/Model/RegisterData.cs new file mode 100644 index 0000000..6d92531 --- /dev/null +++ b/HopFrame.Web/Model/RegisterData.cs @@ -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; +} \ No newline at end of file diff --git a/HopFrame.Web/Model/UserAdd.cs b/HopFrame.Web/Model/UserAdd.cs new file mode 100644 index 0000000..e138395 --- /dev/null +++ b/HopFrame.Web/Model/UserAdd.cs @@ -0,0 +1,5 @@ +namespace HopFrame.Web.Model; + +internal sealed class UserAdd : RegisterData { + public string Group { get; set; } +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/AdminDashboard.razor b/HopFrame.Web/Pages/Administration/AdminDashboard.razor new file mode 100644 index 0000000..e939548 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/AdminDashboard.razor @@ -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 + +Admin Dashboard + + + + @foreach (var view in AdminMenu.Subpages) { + + + + + @view.Name + @view.Permission + @view.Description + Open + + + + + } + + + +@inject NavigationManager Navigator diff --git a/HopFrame.Web/Pages/Administration/AdminLogin.razor b/HopFrame.Web/Pages/Administration/AdminLogin.razor new file mode 100644 index 0000000..f6d8c68 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/AdminLogin.razor @@ -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 + +Login + + + +@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); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/AdminLogin.razor.css b/HopFrame.Web/Pages/Administration/AdminLogin.razor.css new file mode 100644 index 0000000..b92aa21 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/AdminLogin.razor.css @@ -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; +} diff --git a/HopFrame.Web/Pages/Administration/GroupsPage.razor b/HopFrame.Web/Pages/Administration/GroupsPage.razor new file mode 100644 index 0000000..1308db9 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/GroupsPage.razor @@ -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 + +Groups + + + + +
+

+ Groups administration + + + +

+ + + + Add Group + +
+ + + + + + Name + @if (_currentOrder == OrderType.Name) { + + } + + Description + Default + + Created + @if (_currentOrder == OrderType.Created) { + + } + + + @if (_hasEditPrivileges || _hasDeletePrivileges) { + Actions + } + + + + + @foreach (var group in _groups) { + + @group.Name.Replace("group.", "") + @group.Description + + @if (group.IsDefaultGroup) { + Yes + } + else { + No + } + + @group.CreatedAt + + @if (_hasEditPrivileges || _hasDeletePrivileges) { + + + @if (_hasEditPrivileges) { + Edit + } + + @if (_hasDeletePrivileges) { + Delete + } + + + } + + } + + + +@inject IPermissionService Permissions +@inject ITokenContext Auth +@inject SweetAlertService Alerts + +@code { + private IList _groups = new List(); + + 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(AdminPermissions.EditGroup, Auth.User.Id); + _hasDeletePrivileges = await Permissions.HasPermission(AdminPermissions.DeleteGroup, Auth.User.Id); + } + + private async Task Reload() { + _groups = new List(); + + _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 + } +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/GroupsPage.razor.css b/HopFrame.Web/Pages/Administration/GroupsPage.razor.css new file mode 100644 index 0000000..445d132 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/GroupsPage.razor.css @@ -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; +} diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor new file mode 100644 index 0000000..e3a4611 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/AdminLayout.razor @@ -0,0 +1,23 @@ +@using HopFrame.Web.Components +@using BlazorStrap.V5 +@inherits LayoutComponentBase + + + + + +
+ + +
+
+ @Body + +
+
+
+ + + diff --git a/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor new file mode 100644 index 0000000..275afd2 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/AdminMenu.razor @@ -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 + + + + + + HopFrame + + + + + + + + Dashboard + + @foreach (var nav in Subpages) { + + @nav.Name + + } + + + + logged in as @Context?.User.Username + + + + + logout + + + + + + +@inject NavigationManager Navigator +@inject ITokenContext Context +@inject IAuthService Auth + +@code { + public static IList Subpages = new List { + new () { + Name = "Users", + Url = "administration/users", + Description = "On this page you can manage all user accounts.", + Permission = AdminPermissions.ViewUsers + }, + new () { + Name = "Groups", + Url = "administration/groups", + Description = "On this page you can view, create, edit and delete permission groups.", + Permission = 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); + } +} diff --git a/HopFrame.Web/Pages/Administration/Layout/EmptyLayout.razor b/HopFrame.Web/Pages/Administration/Layout/EmptyLayout.razor new file mode 100644 index 0000000..3863c39 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/Layout/EmptyLayout.razor @@ -0,0 +1,9 @@ +@using BlazorStrap.V5 +@inherits LayoutComponentBase + + + +@Body + + + diff --git a/HopFrame.Web/Pages/Administration/UsersPage.razor b/HopFrame.Web/Pages/Administration/UsersPage.razor new file mode 100644 index 0000000..812cdf7 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/UsersPage.razor @@ -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 + +Users + + + + + +
+

+ Users administration + + + +

+ + + + Add User + +
+ + + + + # + + E-Mail + @if (_currentOrder == OrderType.Email) { + + } + + + Username + @if (_currentOrder == OrderType.Username) { + + } + + + Registered + @if (_currentOrder == OrderType.Registered) { + + } + + Primary Group + + @if (_hasEditPrivileges || _hasDeletePrivileges) { + Actions + } + + + + + @foreach (var user in _users) { + + @user.Id + @user.Email + @user.Username + @user.CreatedAt + @GetFriendlyGroupName(user) + + @if (_hasEditPrivileges || _hasDeletePrivileges) { + + + @if (_hasEditPrivileges) { + Edit + } + + @if (_hasDeletePrivileges) { + Delete + } + + + } + + } + + + +@inject IUserService UserService +@inject IPermissionService PermissionsService +@inject SweetAlertService Alerts +@inject ITokenContext Auth + +@code { + private IList _users = new List(); + private IDictionary _userGroups = new Dictionary(); + + 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(AdminPermissions.EditUser, Auth.User.Id); + _hasDeletePrivileges = await PermissionsService.HasPermission(AdminPermissions.DeleteUser, Auth.User.Id); + } + + private async Task Reload() { + _users = new List(); + _userGroups = new Dictionary(); + + _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 + } +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Administration/UsersPage.razor.css b/HopFrame.Web/Pages/Administration/UsersPage.razor.css new file mode 100644 index 0000000..445d132 --- /dev/null +++ b/HopFrame.Web/Pages/Administration/UsersPage.razor.css @@ -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; +} diff --git a/HopFrame.Web/README.md b/HopFrame.Web/README.md new file mode 100644 index 0000000..ec39f17 --- /dev/null +++ b/HopFrame.Web/README.md @@ -0,0 +1,2 @@ +# HopFrame Web module +This module contains useful helpers for Blazor Apps and an Admin Dashboard. diff --git a/HopFrame.Web/ServiceCollectionExtensions.cs b/HopFrame.Web/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8e3c453 --- /dev/null +++ b/HopFrame.Web/ServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +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 AddHopFrameServices(this IServiceCollection services) where TDbContext : HopDbContextBase { + services.AddHttpClient(); + services.AddScoped>(); + services.AddTransient(); + + // Component library's + services.AddSweetAlert2(); + services.AddBlazorStrap(); + + //TODO: Use https://blazorstrap.io/V5/V5 + + services.AddHopFrameAuthentication(); + + return services; + } + + public static RazorComponentsEndpointConventionBuilder AddHopFrameAdminPages(this RazorComponentsEndpointConventionBuilder builder) { + return builder + .DisableAntiforgery() + .AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly) + .AddInteractiveServerRenderMode(); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Services/IAuthService.cs b/HopFrame.Web/Services/IAuthService.cs new file mode 100644 index 0000000..f3e588c --- /dev/null +++ b/HopFrame.Web/Services/IAuthService.cs @@ -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 Login(UserLogin login); + Task Logout(); + + Task RefreshLogin(); + Task IsLoggedIn(); +} \ No newline at end of file diff --git a/HopFrame.Web/Services/Implementation/AuthService.cs b/HopFrame.Web/Services/Implementation/AuthService.cs new file mode 100644 index 0000000..682397a --- /dev/null +++ b/HopFrame.Web/Services/Implementation/AuthService.cs @@ -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( + 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.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, + HttpOnly = false, + Secure = true + }); + } + + public async Task 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.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.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 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.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.RefreshTokenTime < DateTime.Now) + .ToList(); + if (oldRefreshTokens.Count != 0) + context.Tokens.RemoveRange(oldRefreshTokens); + + await context.SaveChangesAsync(); + + if (token.CreatedAt + HopFrameAuthentication.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.AccessTokenTime, + HttpOnly = false, + Secure = true + }); + + return accessToken; + } + + public async Task 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.AccessTokenTime < DateTime.Now) return false; + if (!await context.Users.AnyAsync(user => user.Id == tokenEntry.UserId)) return false; + + return true; + } +} \ No newline at end of file diff --git a/HopFrame.sln b/HopFrame.sln new file mode 100644 index 0000000..453dbcf --- /dev/null +++ b/HopFrame.sln @@ -0,0 +1,52 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Database", "HopFrame.Database\HopFrame.Database.csproj", "{003120AE-F38B-4632-8497-BE4505189627}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{58703056-8DAD-4221-BBE3-42425D2F4929}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiTest", "RestApiTest\RestApiTest.csproj", "{921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Security", "HopFrame.Security\HopFrame.Security.csproj", "{7F82E1C6-4A42-4337-9E03-2EE6429D004F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Api", "HopFrame.Api\HopFrame.Api.csproj", "{1E821490-AEDC-4F55-B758-52F4FADAB53A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "HopFrame.Web\HopFrame.Web.csproj", "{3BE585BC-13A5-4BE4-A806-E9EC2D825956}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontendTest", "FrontendTest\FrontendTest.csproj", "{8F983A37-63CF-48D5-988D-58B78EF8AECD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {003120AE-F38B-4632-8497-BE4505189627}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {003120AE-F38B-4632-8497-BE4505189627}.Debug|Any CPU.Build.0 = Debug|Any CPU + {003120AE-F38B-4632-8497-BE4505189627}.Release|Any CPU.ActiveCfg = Release|Any CPU + {003120AE-F38B-4632-8497-BE4505189627}.Release|Any CPU.Build.0 = Release|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF}.Release|Any CPU.Build.0 = Release|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F82E1C6-4A42-4337-9E03-2EE6429D004F}.Release|Any CPU.Build.0 = Release|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E821490-AEDC-4F55-B758-52F4FADAB53A}.Release|Any CPU.Build.0 = Release|Any CPU + {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BE585BC-13A5-4BE4-A806-E9EC2D825956}.Release|Any CPU.Build.0 = Release|Any CPU + {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F983A37-63CF-48D5-988D-58B78EF8AECD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {921159CE-AF75-44C3-A3F9-6B9B1A4E85CF} = {58703056-8DAD-4221-BBE3-42425D2F4929} + {8F983A37-63CF-48D5-988D-58B78EF8AECD} = {58703056-8DAD-4221-BBE3-42425D2F4929} + EndGlobalSection +EndGlobal diff --git a/HopFrame.sln.DotSettings.user b/HopFrame.sln.DotSettings.user new file mode 100644 index 0000000..f66ed72 --- /dev/null +++ b/HopFrame.sln.DotSettings.user @@ -0,0 +1,5 @@ + + <AssemblyExplorer> + <Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.16\ref\net7.0\System.ComponentModel.Annotations.dll" /> + <Assembly Path="C:\Users\Remote\.nuget\packages\blazorstrap\5.2.100.61524\lib\net7.0\BlazorStrap.dll" /> +</AssemblyExplorer> \ No newline at end of file diff --git a/README.md b/README.md index e059e97..84f0ab2 100644 --- a/README.md +++ b/README.md @@ -4,5 +4,5 @@ A simple backend management api for ASP.NET Core Web APIs # Features - [x] Database management - [x] User authentication -- [ ] Permission management -- [ ] Frontend dashboards +- [x] Permission management +- [x] Frontend dashboards diff --git a/RestApiTest/.gitignore b/RestApiTest/.gitignore new file mode 100644 index 0000000..ab7b4dc --- /dev/null +++ b/RestApiTest/.gitignore @@ -0,0 +1,4 @@ +obj +bin +Migrations +appsettings.Development.json diff --git a/RestApiTest/Controllers/TestController.cs b/RestApiTest/Controllers/TestController.cs new file mode 100644 index 0000000..f13bb74 --- /dev/null +++ b/RestApiTest/Controllers/TestController.cs @@ -0,0 +1,17 @@ +using HopFrame.Database.Models; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Mvc; + +namespace RestApiTest.Controllers; + +[ApiController] +[Route("test")] +public class TestController(ITokenContext userContext) : ControllerBase { + + [HttpGet("permissions"), Authorized] + public ActionResult> Permissions() { + return new ActionResult>(userContext.User.Permissions); + } + +} \ No newline at end of file diff --git a/RestApiTest/DatabaseContext.cs b/RestApiTest/DatabaseContext.cs new file mode 100644 index 0000000..fed4058 --- /dev/null +++ b/RestApiTest/DatabaseContext.cs @@ -0,0 +1,12 @@ +using HopFrame.Database; +using Microsoft.EntityFrameworkCore; + +namespace RestApiTest; + +public class DatabaseContext : HopDbContextBase { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseSqlite("Data Source=C:\\Users\\Remote\\Documents\\Projekte\\HopFrame\\RestApiTest\\bin\\Debug\\net8.0\\test.db;Mode=ReadWrite;"); + } +} \ No newline at end of file diff --git a/RestApiTest/Program.cs b/RestApiTest/Program.cs new file mode 100644 index 0000000..bfcbc38 --- /dev/null +++ b/RestApiTest/Program.cs @@ -0,0 +1,56 @@ +using RestApiTest; +using HopFrame.Api.Extensions; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +builder.Services.AddHopFrame(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContext(); + +builder.Services.AddSwaggerGen(c => { + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { + Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n + Enter 'Bearer' [space] and then your token in the text input below.", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement {{ + new OpenApiSecurityScheme { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + ArraySegment.Empty + }}); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) { + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/RestApiTest/Properties/launchSettings.json b/RestApiTest/Properties/launchSettings.json new file mode 100644 index 0000000..6418a5c --- /dev/null +++ b/RestApiTest/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:19326", + "sslPort": 44320 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7283;http://localhost:5158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/RestApiTest/RestApiTest.csproj b/RestApiTest/RestApiTest.csproj new file mode 100644 index 0000000..936e228 --- /dev/null +++ b/RestApiTest/RestApiTest.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/RestApiTest/appsettings.json b/RestApiTest/appsettings.json new file mode 100644 index 0000000..470eccb --- /dev/null +++ b/RestApiTest/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "HopFrame.Security.Authentication.HopFrameAuthentication": "None" + } + }, + "AllowedHosts": "*" +} diff --git a/docs/Diagrams/Models/ApiModels.puml b/docs/Diagrams/Models/ApiModels.puml new file mode 100644 index 0000000..2019855 --- /dev/null +++ b/docs/Diagrams/Models/ApiModels.puml @@ -0,0 +1,34 @@ +@startuml ApiModels + +namespace HopFrame.Security { + class UserLogin { + +Email: string + +Password: string + } + + class UserRegister { + +Username: string + +Email: string + +Password: string + } +} + +namespace HopFrame.Web { + class RegisterData { + +RepeatedPassword: string + } +} + +namespace HopFrame.Api { + class SingleValueResult { + +Value: TValue + } + + class UserPasswordValidation { + +Password: string + } +} + +UserRegister <|-- RegisterData + +@enduml \ No newline at end of file diff --git a/docs/Diagrams/Models/BaseModels.puml b/docs/Diagrams/Models/BaseModels.puml new file mode 100644 index 0000000..62706fc --- /dev/null +++ b/docs/Diagrams/Models/BaseModels.puml @@ -0,0 +1,37 @@ +@startuml BaseModels +set namespaceSeparator none + +namespace HopFrame.Database { + class User { + +Id: Guid + +Username: string + +Email: string + +CreatedAt: DateTime + +Permissions: IList + } + + class Permission { + +Id: long + +PermissionName: string + +Owner: Guid + +GrantedAt: DateTime + } + + class PermissionGroup { + +Name: string + +IsDefaultGroup: bool + +Description: string + +CreatedAt: DateTime + +Permissions: IList + } + + interface IPermissionOwner {} +} + +IPermissionOwner <|-- User +IPermissionOwner <|-- PermissionGroup + +User .. Permission +PermissionGroup .. Permission + +@enduml \ No newline at end of file diff --git a/docs/Diagrams/Models/DatabaseModels.puml b/docs/Diagrams/Models/DatabaseModels.puml new file mode 100644 index 0000000..fc3a7b9 --- /dev/null +++ b/docs/Diagrams/Models/DatabaseModels.puml @@ -0,0 +1,41 @@ +@startuml DatabaseModels +set namespaceSeparator none + +namespace HopFrame.Database { + class UserEntry { + +Id: string + +Username: string + +Email: string + +Password: string + +CreatedAt: DateTime + } + + class TokenEntry { + {static} +RefreshTokenType: int = 0 + {static} +AccessTokenType: int = 1 + + +Type: int + +Token: string + +UserId: string + +CreatedAt: DateTime + } + + class PermissionEntry { + +RecordId: long + +PermissionText: string + +UserId: string + +GrantedAt: DateTime + } + + class GroupEntry { + +Name: string + +Default: bool + +Description: string + +CreatedAt: DateTime + } +} + +UserEntry *-- TokenEntry +UserEntry *-- PermissionEntry + +@enduml \ No newline at end of file diff --git a/docs/Diagrams/Models/img/ApiModels.svg b/docs/Diagrams/Models/img/ApiModels.svg new file mode 100644 index 0000000..82ff3c0 --- /dev/null +++ b/docs/Diagrams/Models/img/ApiModels.svg @@ -0,0 +1 @@ +HopFrameSecurityWebApiUserLoginEmail: stringPassword: stringUserRegisterUsername: stringEmail: stringPassword: stringRegisterDataRepeatedPassword: stringSingleValueResultTValueValue: TValueUserPasswordValidationPassword: string \ No newline at end of file diff --git a/docs/Diagrams/Models/img/BaseModels.svg b/docs/Diagrams/Models/img/BaseModels.svg new file mode 100644 index 0000000..89ed8d2 --- /dev/null +++ b/docs/Diagrams/Models/img/BaseModels.svg @@ -0,0 +1 @@ +HopFrame.DatabaseUserId: GuidUsername: stringEmail: stringCreatedAt: DateTimePermissions: IList<Permission>PermissionId: longPermissionName: stringOwner: GuidGrantedAt: DateTimePermissionGroupName: stringIsDefaultGroup: boolDescription: stringCreatedAt: DateTimePermissions: IList<Permission>IPermissionOwner \ No newline at end of file diff --git a/docs/Diagrams/Models/img/DatabaseModels.svg b/docs/Diagrams/Models/img/DatabaseModels.svg new file mode 100644 index 0000000..06aa882 --- /dev/null +++ b/docs/Diagrams/Models/img/DatabaseModels.svg @@ -0,0 +1 @@ +HopFrame.DatabaseUserEntryId: stringUsername: stringEmail: stringPassword: stringCreatedAt: DateTimeTokenEntryRefreshTokenType: int = 0AccessTokenType: int = 1 Type: intToken: stringUserId: stringCreatedAt: DateTimePermissionEntryRecordId: longPermissionText: stringUserId: stringGrantedAt: DateTimeGroupEntryName: stringDefault: boolDescription: stringCreatedAt: DateTime \ No newline at end of file diff --git a/docs/Models/README.md b/docs/Models/README.md new file mode 100644 index 0000000..03273b6 --- /dev/null +++ b/docs/Models/README.md @@ -0,0 +1,21 @@ +# Models for HopFrame + +This page shows all models that HopFrame uses. + + +## Base Models +These are the models used by the various database services. + +![](../Diagrams/Models/img/BaseModels.svg) + + +## API Models +These are the models used by the REST API and the Blazor API. + +![](../Diagrams/Models/img/ApiModels.svg) + + +## Database Models +These are the models that correspond to the scheme in the Database + +![](../Diagrams/Models/img/DatabaseModels.svg) diff --git a/global.json b/global.json new file mode 100644 index 0000000..2ddda36 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file