From 7cd412b168826ac010301e5f4ae080fd2a14b75e Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 14 Jul 2024 21:25:36 +0200 Subject: [PATCH] Added automatic token refresh feature and login page --- FrontendTest/Components/Pages/Counter.razor | 6 +- FrontendTest/Components/Pages/Home.razor | 3 + FrontendTest/Program.cs | 4 +- .../Authorization/AuthorizedFilter.cs | 2 +- .../Claims/TokenContextImplementor.cs | 4 +- HopFrame.Security/Models/UserLogin.cs | 2 +- .../Services/IPermissionService.cs | 2 +- HopFrame.Web/AuthMiddleware.cs | 35 +++++++++++ HopFrame.Web/Components/AuthorizedView.razor | 20 +++++- HopFrame.Web/Pages/Login.razor | 61 +++++++++++++++++++ HopFrame.Web/Pages/Login.razor.css | 15 +++++ HopFrame.Web/Pages/Register.razor | 13 +++- HopFrame.Web/Pages/Register.razor.css | 1 + HopFrame.Web/ServiceCollectionExtensions.cs | 4 ++ HopFrame.Web/Services/IAuthService.cs | 3 +- .../Services/Implementation/AuthService.cs | 14 ++--- 16 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 HopFrame.Web/AuthMiddleware.cs create mode 100644 HopFrame.Web/Pages/Login.razor create mode 100644 HopFrame.Web/Pages/Login.razor.css diff --git a/FrontendTest/Components/Pages/Counter.razor b/FrontendTest/Components/Pages/Counter.razor index a292814..91535dd 100644 --- a/FrontendTest/Components/Pages/Counter.razor +++ b/FrontendTest/Components/Pages/Counter.razor @@ -6,11 +6,9 @@

Counter

- -

Current count: @currentCount

+

Current count: @currentCount

- -
+ @code { private int currentCount = 0; diff --git a/FrontendTest/Components/Pages/Home.razor b/FrontendTest/Components/Pages/Home.razor index f699b5a..b6f408e 100644 --- a/FrontendTest/Components/Pages/Home.razor +++ b/FrontendTest/Components/Pages/Home.razor @@ -1,5 +1,8 @@ @page "/" @using HopFrame.Security.Claims +@using HopFrame.Web.Components + + Home diff --git a/FrontendTest/Program.cs b/FrontendTest/Program.cs index 2a74ace..ea90ec6 100644 --- a/FrontendTest/Program.cs +++ b/FrontendTest/Program.cs @@ -1,12 +1,10 @@ using FrontendTest; using FrontendTest.Components; -using HopFrame.Security.Authentication; using HopFrame.Web; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(); -builder.Services.AddHopFrameAuthentication(); builder.Services.AddHopFrameServices(); // Add services to the container. @@ -27,6 +25,8 @@ app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseAntiforgery(); app.UseAuthorization(); +app.UseAuthentication(); +app.UseMiddleware(); app.MapRazorComponents() .AddHopFramePages() diff --git a/HopFrame.Security/Authorization/AuthorizedFilter.cs b/HopFrame.Security/Authorization/AuthorizedFilter.cs index 66f7e27..13f5932 100644 --- a/HopFrame.Security/Authorization/AuthorizedFilter.cs +++ b/HopFrame.Security/Authorization/AuthorizedFilter.cs @@ -15,7 +15,7 @@ public class AuthorizedFilter : IAuthorizationFilter { public void OnAuthorization(AuthorizationFilterContext context) { if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return; - if (context.HttpContext.User.Identity?.IsAuthenticated == false) { + if (string.IsNullOrEmpty(context.HttpContext.User.GetAccessTokenId())) { context.Result = new UnauthorizedResult(); return; } diff --git a/HopFrame.Security/Claims/TokenContextImplementor.cs b/HopFrame.Security/Claims/TokenContextImplementor.cs index 9546f08..049923f 100644 --- a/HopFrame.Security/Claims/TokenContextImplementor.cs +++ b/HopFrame.Security/Claims/TokenContextImplementor.cs @@ -5,11 +5,11 @@ using Microsoft.AspNetCore.Http; namespace HopFrame.Security.Claims; internal class TokenContextImplementor(IHttpContextAccessor accessor, TDbContext context) : ITokenContext where TDbContext : HopDbContextBase { - public bool IsAuthenticated => accessor.HttpContext?.User.Identity?.IsAuthenticated == true; + 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() ?? string.Empty); + public Guid AccessToken => Guid.Parse(accessor.HttpContext?.User.GetAccessTokenId() ?? Guid.Empty.ToString()); } \ No newline at end of file diff --git a/HopFrame.Security/Models/UserLogin.cs b/HopFrame.Security/Models/UserLogin.cs index c079b2d..6faa31b 100644 --- a/HopFrame.Security/Models/UserLogin.cs +++ b/HopFrame.Security/Models/UserLogin.cs @@ -1,6 +1,6 @@ namespace HopFrame.Security.Models; -public struct UserLogin { +public class UserLogin { public string Email { get; set; } public string Password { get; set; } } \ No newline at end of file diff --git a/HopFrame.Security/Services/IPermissionService.cs b/HopFrame.Security/Services/IPermissionService.cs index 1564aea..cb5561a 100644 --- a/HopFrame.Security/Services/IPermissionService.cs +++ b/HopFrame.Security/Services/IPermissionService.cs @@ -26,6 +26,6 @@ public interface IPermissionService { Task DeletePermission(Permission permission); - internal Task GetFullPermissions(string user); + Task GetFullPermissions(string user); } \ No newline at end of file diff --git a/HopFrame.Web/AuthMiddleware.cs b/HopFrame.Web/AuthMiddleware.cs new file mode 100644 index 0000000..ef18647 --- /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 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/AuthorizedView.razor b/HopFrame.Web/Components/AuthorizedView.razor index d68512a..6c3472f 100644 --- a/HopFrame.Web/Components/AuthorizedView.razor +++ b/HopFrame.Web/Components/AuthorizedView.razor @@ -2,12 +2,13 @@ @using HopFrame.Security.Claims @using Microsoft.AspNetCore.Http -@if (IsAuthorized()) { +@if (HandleComponent()) { @ChildContent } @inject ITokenContext Auth @inject IHttpContextAccessor HttpAccessor +@inject NavigationManager Navigator @code { [Parameter] @@ -16,14 +17,17 @@ [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.Length == 0 && string.IsNullOrEmpty(Permission)) return true; + if ((Permissions == null || Permissions.Length == 0) && string.IsNullOrEmpty(Permission)) return true; - var perms = new List(Permissions); + var perms = new List(Permissions!); if (!string.IsNullOrEmpty(Permission)) perms.Add(Permission); var permissions = HttpAccessor.HttpContext?.User.GetPermissions(); @@ -31,4 +35,14 @@ 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/Pages/Login.razor b/HopFrame.Web/Pages/Login.razor new file mode 100644 index 0000000..09f252b --- /dev/null +++ b/HopFrame.Web/Pages/Login.razor @@ -0,0 +1,61 @@ +@page "/login" +@using HopFrame.Security.Models +@using HopFrame.Web.Services +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web + +Login + + + +@inject IAuthService Auth +@inject NavigationManager Navigator + +@code { + [SupplyParameterFromForm] + private UserLogin LoginData { get; set; } + + private bool _loginError; + + protected override void OnInitialized() { + LoginData ??= new(); + } + + private async Task OnLogin() { + var result = await Auth.Login(LoginData); + + if (!result) { + _loginError = true; + return; + } + + Navigator.NavigateTo(Register.RedirectAfterRegister, true); + } +} \ No newline at end of file diff --git a/HopFrame.Web/Pages/Login.razor.css b/HopFrame.Web/Pages/Login.razor.css new file mode 100644 index 0000000..b92aa21 --- /dev/null +++ b/HopFrame.Web/Pages/Login.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/Register.razor b/HopFrame.Web/Pages/Register.razor index fba640b..976c516 100644 --- a/HopFrame.Web/Pages/Register.razor +++ b/HopFrame.Web/Pages/Register.razor @@ -3,13 +3,18 @@ @using HopFrame.Web.Model @using HopFrame.Web.Services @using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web @implements IDisposable +Register +
@**@
+

Register

@@ -31,18 +36,20 @@
+
+ Already have an account? Login +
-@inject NavigationManager Navigation +@inject NavigationManager Navigator @inject IUserService Users @inject IAuthService Auth @code { public static string RedirectAfterRegister { get; set; } = "/"; - private const string RefreshTokenType = "HopFrame.Security.RefreshToken"; [SupplyParameterFromForm] private RegisterData RegisterData { get; set; } @@ -74,7 +81,7 @@ if (hasConflict) return; await Auth.Register(RegisterData); - Navigation.NavigateTo(RedirectAfterRegister, true); + Navigator.NavigateTo(RedirectAfterRegister, true); } private void ValidateForm(object sender, ValidationRequestedEventArgs e) { diff --git a/HopFrame.Web/Pages/Register.razor.css b/HopFrame.Web/Pages/Register.razor.css index aa73cb1..5bdc513 100644 --- a/HopFrame.Web/Pages/Register.razor.css +++ b/HopFrame.Web/Pages/Register.razor.css @@ -11,4 +11,5 @@ padding: 30px; border: 2px solid #ced4da; border-radius: 10px; + position: relative; } diff --git a/HopFrame.Web/ServiceCollectionExtensions.cs b/HopFrame.Web/ServiceCollectionExtensions.cs index 30f9ae0..87eb042 100644 --- a/HopFrame.Web/ServiceCollectionExtensions.cs +++ b/HopFrame.Web/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using HopFrame.Database; +using HopFrame.Security.Authentication; using HopFrame.Web.Services; using HopFrame.Web.Services.Implementation; using Microsoft.AspNetCore.Builder; @@ -10,6 +11,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddHopFrameServices(this IServiceCollection services) where TDbContext : HopDbContextBase { services.AddHttpClient(); services.AddScoped>(); + services.AddTransient(); + + services.AddHopFrameAuthentication(); return services; } diff --git a/HopFrame.Web/Services/IAuthService.cs b/HopFrame.Web/Services/IAuthService.cs index ab4ddc8..f3e588c 100644 --- a/HopFrame.Web/Services/IAuthService.cs +++ b/HopFrame.Web/Services/IAuthService.cs @@ -1,3 +1,4 @@ +using HopFrame.Database.Models.Entries; using HopFrame.Security.Models; namespace HopFrame.Web.Services; @@ -7,6 +8,6 @@ public interface IAuthService { Task Login(UserLogin login); Task Logout(); - Task RefreshLogin(); + 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 index 67cef46..bebb28e 100644 --- a/HopFrame.Web/Services/Implementation/AuthService.cs +++ b/HopFrame.Web/Services/Implementation/AuthService.cs @@ -51,7 +51,7 @@ public class AuthService( var user = await userService.GetUserByEmail(login.Email); if (user == null) return false; - if (await userService.CheckUserPassword(user, login.Password)) return false; + if (await userService.CheckUserPassword(user, login.Password) == false) return false; var refreshToken = new TokenEntry { CreatedAt = DateTime.Now, @@ -100,7 +100,7 @@ public class AuthService( httpAccessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); } - public async Task RefreshLogin() { + public async Task RefreshLogin() { if (await IsLoggedIn()) { var oldToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType]; var entry = await context.Tokens.SingleOrDefaultAsync(token => token.Token == oldToken); @@ -110,14 +110,14 @@ public class AuthService( } } - var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.AccessTokenType]; + var refreshToken = httpAccessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType]; - if (string.IsNullOrWhiteSpace(refreshToken)) return false; + 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 false; - if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return false; + if (token is null) return null; + if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return null; var accessToken = new TokenEntry { CreatedAt = DateTime.Now, @@ -135,7 +135,7 @@ public class AuthService( Secure = true }); - return true; + return accessToken; } public async Task IsLoggedIn() {