From b448229d880aadb9a773af04bf28eede9e973a2a Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Thu, 26 Sep 2024 11:19:41 +0200 Subject: [PATCH] Moved Auth logic to seperate service + endpoints can be disabled --- .../Controller/SecurityController.cs | 152 +--------------- .../Extensions/ServiceCollectionExtensions.cs | 20 ++- src/HopFrame.Api/Logic/IAuthLogic.cs | 16 ++ .../Logic/Implementation/AuthLogic.cs | 166 ++++++++++++++++++ src/HopFrame.Api/README.md | 63 ++++++- 5 files changed, 268 insertions(+), 149 deletions(-) create mode 100644 src/HopFrame.Api/Logic/IAuthLogic.cs create mode 100644 src/HopFrame.Api/Logic/Implementation/AuthLogic.cs diff --git a/src/HopFrame.Api/Controller/SecurityController.cs b/src/HopFrame.Api/Controller/SecurityController.cs index de9a50a..d9c1128 100644 --- a/src/HopFrame.Api/Controller/SecurityController.cs +++ b/src/HopFrame.Api/Controller/SecurityController.cs @@ -1,176 +1,38 @@ 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 { +[Route("api/v1/authentication")] +public class SecurityController(IAuthLogic auth) : ControllerBase { [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 - }); - HttpContext.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { - MaxAge = HopFrameAuthentication.AccessTokenTime, - HttpOnly = true, - Secure = true - }); - - await context.Tokens.AddRangeAsync(refreshToken, accessToken); - await context.SaveChangesAsync(); - - return LogicResult>.Ok(accessToken.Token); + return await auth.Login(login); } [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); + return await auth.Register(register); } [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); + return await auth.Authenticate(); } [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(); + return await auth.Logout(); } [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); - HttpContext.Response.Cookies.Delete(ITokenContext.AccessTokenType); - - return LogicResult.Ok(); + return await auth.Delete(validation); } } \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 568580e..29feb38 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,19 +1,35 @@ using HopFrame.Api.Controller; +using HopFrame.Api.Logic; +using HopFrame.Api.Logic.Implementation; using HopFrame.Database; using HopFrame.Security.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace HopFrame.Api.Extensions; public static class ServiceCollectionExtensions { /// - /// Adds all HopFrame endpoints and the HopFrame security layer to the WebApplication + /// Adds all HopFrame endpoints and services to the application /// /// 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.AddMvcCore().UseSpecificControllers(typeof(SecurityController)); + AddHopFrameNoEndpoints(services); + } + + /// + /// Adds all HopFrame services to the application + /// + /// The service provider to add the services to + /// The data source for all HopFrame entities + public static void AddHopFrameNoEndpoints(this IServiceCollection services) where TDbContext : HopDbContextBase { + services.TryAddSingleton(); + services.AddScoped>(); + services.AddHopFrameAuthentication(); } diff --git a/src/HopFrame.Api/Logic/IAuthLogic.cs b/src/HopFrame.Api/Logic/IAuthLogic.cs new file mode 100644 index 0000000..7dc5b78 --- /dev/null +++ b/src/HopFrame.Api/Logic/IAuthLogic.cs @@ -0,0 +1,16 @@ +using HopFrame.Api.Models; +using HopFrame.Security.Models; + +namespace HopFrame.Api.Logic; + +public interface IAuthLogic { + Task>> Login(UserLogin login); + + Task>> Register(UserRegister register); + + Task>> Authenticate(); + + Task Logout(); + + Task Delete(UserPasswordValidation validation); +} \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs new file mode 100644 index 0000000..405c2cf --- /dev/null +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -0,0 +1,166 @@ +using HopFrame.Api.Models; +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.Api.Logic.Implementation; + +public class AuthLogic(TDbContext context, IUserService users, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic where TDbContext : HopDbContextBase { + + public async Task>> Login(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() + }; + + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, + HttpOnly = true, + Secure = true + }); + + await context.Tokens.AddRangeAsync(refreshToken, accessToken); + await context.SaveChangesAsync(); + + return LogicResult>.Ok(accessToken.Token); + } + + public async Task>> Register(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(); + + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.RefreshTokenTime, + HttpOnly = true, + Secure = true + }); + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, + HttpOnly = false, + Secure = true + }); + + return LogicResult>.Ok(accessToken.Token); + } + + public async Task>> Authenticate() { + var refreshToken = accessor.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(); + + accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Token, new CookieOptions { + MaxAge = HopFrameAuthentication.AccessTokenTime, + HttpOnly = false, + Secure = true + }); + + return LogicResult>.Ok(accessToken.Token); + } + + public async Task Logout() { + var accessToken = accessor.HttpContext?.User.GetAccessTokenId(); + var refreshToken = accessor.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(); + + accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); + accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); + + return LogicResult.Ok(); + } + + public async Task Delete(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); + + accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType); + accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType); + + return LogicResult.Ok(); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/README.md b/src/HopFrame.Api/README.md index 994c8c5..67b946b 100644 --- a/src/HopFrame.Api/README.md +++ b/src/HopFrame.Api/README.md @@ -33,9 +33,68 @@ By default, the module provides a controller for handling authentication based r You can explore the contoller by the build in swagger site from ASP .NET. ## Disable the Endpoints -If you don't want to include these endpoints you need to comment out the AddHopFrame line and only add the Auth middleware: + ```csharp builder.Services.AddDbContext(); //builder.Services.AddHopFrame(); -services.AddHopFrameAuthentication(); +services.AddHopFrameNoEndpoints(); ``` + +# Services added in this module +You can use these services by specifying them as a dependency. All of them are scoped dependencies. + +## LogicResult +Logic result is an extension of the ActionResult for an ApiController. It provides simple Http status results with either a message or data by specifying the generic type. + +```csharp +public class LogicResult : ILogicResult { + public static LogicResult Ok(); + + public static LogicResult BadRequest(); + + public static LogicResult BadRequest(string message); + + public static LogicResult Forbidden(); + + public static LogicResult Forbidden(string message); + + public static LogicResult NotFound(); + + public static LogicResult NotFound(string message); + + public static LogicResult Conflict(); + + public static LogicResult Conflict(string message); + + public static LogicResult Forward(LogicResult result); + + public static LogicResult Forward(ILogicResult result); + + public static implicit operator ActionResult(LogicResult v); +} + +public class LogicResult : ILogicResult { + public static LogicResult Ok(); + + public static LogicResult Ok(T result); + + ... +} +``` + +## IAuthLogic +This service handles all logic needed to provide the authentication endpoints by using the LogicResults. + +```csharp +public interface IAuthLogic { + Task>> Login(UserLogin login); + + Task>> Register(UserRegister register); + + Task>> Authenticate(); + + Task Logout(); + + Task Delete(UserPasswordValidation validation); +} +``` \ No newline at end of file