diff --git a/DotBased.ASP.Auth/AuthDataCache.cs b/DotBased.ASP.Auth/AuthDataCache.cs index be49f50..10bfd1c 100644 --- a/DotBased.ASP.Auth/AuthDataCache.cs +++ b/DotBased.ASP.Auth/AuthDataCache.cs @@ -14,41 +14,26 @@ public class AuthDataCache private readonly CacheNodeCollection _authenticationStateCollection = []; - public Result PurgeSessionFromCache(string id) => _authenticationStateCollection.Remove(id) ? Result.Ok() : Result.Failed("Failed to purge session state from cache! Or the session was not cached..."); + public Result PurgeSessionState(string id) => _authenticationStateCollection.Remove(id) ? Result.Ok() : Result.Failed("Failed to purge session state from cache! Or the session was not cached..."); - public async Task> RequestAuthStateAsync(IAuthDataRepository dataRepository, string id) + public void CacheSessionState(AuthenticationStateModel state) => _authenticationStateCollection.Insert(new CacheNode(state)); + + public Result RequestSessionState(string id) { - if (_authenticationStateCollection.TryGetValue(id, out var node)) + if (!_authenticationStateCollection.TryGetValue(id, out var node)) + return Result.Failed("No cached object found!"); + string failedMsg; + if (node.Object != null) { - if (node.Object == null) - { - _authenticationStateCollection.Remove(id); - return Result.Failed($"Returned object is null, removing entry [{id}] from cache!"); - } - if (node.IsValidLifespan(_configuration.CachedAuthSessionLifespan)) return Result.Ok(node.Object); + failedMsg = $"Session has invalid lifespan, removing entry: [{id}] from cache!"; } - - var dbResult = await dataRepository.GetAuthenticationStateAsync(id); - if (!dbResult.Success || dbResult.Value == null) - { - _authenticationStateCollection.Remove(id); - return Result.Failed("Unknown session state!"); - } - - if (node == null) - node = new CacheNode(dbResult.Value); else - node.UpdateObject(dbResult.Value); - if (node.Object != null) - return Result.Ok(node.Object); - return node.Object != null ? Result.Ok(node.Object) : Result.Failed("Failed to get db object!"); + failedMsg = $"Returned object is null, removing entry: [{id}] from cache!"; + _authenticationStateCollection.Remove(id); + return Result.Failed(failedMsg); } - - /* - * - */ } public class CacheNode where T : class @@ -86,4 +71,24 @@ public class CacheNode where T : class public class CacheNodeCollection : KeyedCollection> where TItem : class { protected override string GetKeyForItem(CacheNode item) => item.Object?.ToString() ?? string.Empty; + + public new CacheNode? this[string id] + { + get => TryGetValue(id, out CacheNode? nodeValue) ? nodeValue : null; + set + { + if (value == null) + return; + if (TryGetValue(id, out CacheNode? nodeValue)) + Remove(nodeValue); + Add(value); + } + } + + public void Insert(CacheNode node) + { + if (Contains(node)) + Remove(node); + Add(node); + } } \ No newline at end of file diff --git a/DotBased.ASP.Auth/BasedAuthConfiguration.cs b/DotBased.ASP.Auth/BasedAuthConfiguration.cs index e4c74a4..befc359 100644 --- a/DotBased.ASP.Auth/BasedAuthConfiguration.cs +++ b/DotBased.ASP.Auth/BasedAuthConfiguration.cs @@ -21,6 +21,10 @@ public class BasedAuthConfiguration /// public string LogoutPath { get; set; } = string.Empty; /// + /// The page that the client will be redirected to after logging out. + /// + public string LoggedOutPath { get; set; } = string.Empty; + /// /// The max age before a AuthenticationState will expire (default: 7 days). /// public TimeSpan AuthenticationStateMaxAgeBeforeExpire { get; set; } = TimeSpan.FromDays(7); diff --git a/DotBased.ASP.Auth/BasedAuthDefaults.cs b/DotBased.ASP.Auth/BasedAuthDefaults.cs new file mode 100644 index 0000000..51009df --- /dev/null +++ b/DotBased.ASP.Auth/BasedAuthDefaults.cs @@ -0,0 +1,6 @@ +namespace DotBased.ASP.Auth; + +public static class BasedAuthDefaults +{ + public const string AuthenticationScheme = "DotBasedAuthentication"; +} \ No newline at end of file diff --git a/DotBased.ASP.Auth/BasedServerAuthenticationStateProvider.cs b/DotBased.ASP.Auth/BasedServerAuthenticationStateProvider.cs index f64a643..6ac6a46 100644 --- a/DotBased.ASP.Auth/BasedServerAuthenticationStateProvider.cs +++ b/DotBased.ASP.Auth/BasedServerAuthenticationStateProvider.cs @@ -1,8 +1,11 @@ using System.Security.Claims; -using DotBased.ASP.Auth.Scheme; +using DotBased.ASP.Auth.Services; using DotBased.Logging; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using Microsoft.AspNetCore.Http; using ILogger = DotBased.Logging.ILogger; namespace DotBased.ASP.Auth; @@ -12,22 +15,30 @@ namespace DotBased.ASP.Auth; // Handles roles public class BasedServerAuthenticationStateProvider : ServerAuthenticationStateProvider { - public BasedServerAuthenticationStateProvider(BasedAuthConfiguration configuration, ISessionStateProvider stateProvider) + public BasedServerAuthenticationStateProvider(BasedAuthConfiguration configuration, ProtectedLocalStorage localStorage, SecurityService securityService) { _config = configuration; - _stateProvider = stateProvider; + //_stateProvider = stateProvider; + _localStorage = localStorage; + _securityService = securityService; _logger = LogService.RegisterLogger(typeof(BasedServerAuthenticationStateProvider)); } private BasedAuthConfiguration _config; private ISessionStateProvider _stateProvider; + private ProtectedLocalStorage _localStorage; + private SecurityService _securityService; private ILogger _logger; - private readonly AuthenticationState _loggedInState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(new List() { new Claim(ClaimTypes.Role, "Admin"), new Claim(ClaimTypes.Name, "Anon") }, BasedAuthenticationHandler.AuthenticationScheme))); + private readonly AuthenticationState _loggedInState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(new List() { new Claim(ClaimTypes.Role, "Admin"),new Claim(ClaimTypes.Role, "nottest"), new Claim(ClaimTypes.Name, "Anon") }, BasedAuthDefaults.AuthenticationScheme))); private readonly AuthenticationState _anonState = new AuthenticationState(new ClaimsPrincipal()); - public override Task GetAuthenticationStateAsync() + public override async Task GetAuthenticationStateAsync() { - return Task.FromResult(_loggedInState); + var sessionIdResult = await _localStorage.GetAsync("dotbased_session"); + if (!sessionIdResult.Success || sessionIdResult.Value == null) + return _anonState; + var stateResult = await _securityService.GetAuthenticationFromSession(sessionIdResult.Value); + return stateResult is { Success: true, Value: not null } ? stateResult.Value : _anonState; } } \ No newline at end of file diff --git a/DotBased.ASP.Auth/DotBasedAuthDependencyInjection.cs b/DotBased.ASP.Auth/DotBasedAuthDependencyInjection.cs index 7c1fb23..c64cbca 100644 --- a/DotBased.ASP.Auth/DotBasedAuthDependencyInjection.cs +++ b/DotBased.ASP.Auth/DotBasedAuthDependencyInjection.cs @@ -1,4 +1,3 @@ -using DotBased.ASP.Auth.Scheme; using DotBased.ASP.Auth.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Authorization; @@ -23,18 +22,18 @@ public static class DotBasedAuthDependencyInjection if (Configuration.AuthDataRepositoryType == null) throw new ArgumentNullException(nameof(Configuration.AuthDataRepositoryType), $"No '{nameof(IAuthDataRepository)}' configured!"); services.AddScoped(typeof(IAuthDataRepository), Configuration.AuthDataRepositoryType); - if (Configuration.SessionStateProviderType == null) + /*if (Configuration.SessionStateProviderType == null) throw new ArgumentNullException(nameof(Configuration.SessionStateProviderType), $"No '{nameof(ISessionStateProvider)}' configured!"); - services.AddScoped(typeof(ISessionStateProvider), Configuration.SessionStateProviderType); + services.AddScoped(typeof(ISessionStateProvider), Configuration.SessionStateProviderType);*/ services.AddSingleton(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddAuthentication(options => { - options.DefaultScheme = BasedAuthenticationHandler.AuthenticationScheme; - }).AddScheme(BasedAuthenticationHandler.AuthenticationScheme, null); + options.DefaultScheme = BasedAuthDefaults.AuthenticationScheme; + });/*.AddScheme(BasedAuthDefaults.AuthenticationScheme, null);*/ services.AddAuthorization(); services.AddCascadingAuthenticationState(); return services; diff --git a/DotBased.ASP.Auth/MemoryAuthDataRepository.cs b/DotBased.ASP.Auth/MemoryAuthDataRepository.cs index 7e0e87b..01edad6 100644 --- a/DotBased.ASP.Auth/MemoryAuthDataRepository.cs +++ b/DotBased.ASP.Auth/MemoryAuthDataRepository.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using DotBased.ASP.Auth.Domains.Auth; using DotBased.ASP.Auth.Domains.Identity; +using DotBased.Extensions; namespace DotBased.ASP.Auth; /// @@ -9,21 +10,17 @@ namespace DotBased.ASP.Auth; [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] public class MemoryAuthDataRepository : IAuthDataRepository { - private readonly List _userList = []; - private readonly List _groupList = []; - private readonly List _authenticationStateList = []; - public async Task CreateUserAsync(UserModel user) { - if (_userList.Any(x => x.Id == user.Id || x.Email == user.Email)) + if (MemoryData.users.Any(x => x.Id == user.Id || x.Email == user.Email)) return Result.Failed("User already exists."); - _userList.Add(user); + MemoryData.users.Add(user); return Result.Ok(); } public async Task UpdateUserAsync(UserModel user) { - if (_userList.All(x => x.Id != user.Id)) + if (MemoryData.users.All(x => x.Id != user.Id)) return Result.Failed("User does not exist!"); return Result.Ok(); @@ -34,9 +31,16 @@ public class MemoryAuthDataRepository : IAuthDataRepository throw new NotImplementedException(); } - public Task> GetUserAsync(string id, string email, string username) + public async Task> GetUserAsync(string id, string email, string username) { - throw new NotImplementedException(); + UserModel? userModel = null; + if (!id.IsNullOrWhiteSpace()) + userModel = MemoryData.users.FirstOrDefault(u => u.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + if (!email.IsNullOrWhiteSpace()) + userModel = MemoryData.users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase)); + if (!username.IsNullOrWhiteSpace()) + userModel = MemoryData.users.FirstOrDefault(u => u.UserName.Equals(username, StringComparison.OrdinalIgnoreCase)); + return userModel != null ? Result.Ok(userModel) : Result.Failed("No user found!"); } public Task> GetUsersAsync(int start = 0, int amount = 30, string search = "") @@ -69,9 +73,11 @@ public class MemoryAuthDataRepository : IAuthDataRepository throw new NotImplementedException(); } - public Task CreateAuthenticationStateAsync(AuthenticationStateModel authenticationState) + public async Task CreateAuthenticationStateAsync(AuthenticationStateModel authenticationState) { - throw new NotImplementedException(); + if (MemoryData.AuthenticationStates.Contains(authenticationState)) return Result.Failed("Item already exists!"); + MemoryData.AuthenticationStates.Add(authenticationState); + return Result.Ok(); } public Task UpdateAuthenticationStateAsync(AuthenticationStateModel authenticationState) @@ -79,13 +85,23 @@ public class MemoryAuthDataRepository : IAuthDataRepository throw new NotImplementedException(); } - public Task DeleteAuthenticationStateAsync(AuthenticationStateModel authenticationState) + public async Task DeleteAuthenticationStateAsync(AuthenticationStateModel authenticationState) { - throw new NotImplementedException(); + MemoryData.AuthenticationStates.Remove(authenticationState); + return Result.Ok(); } - public Task> GetAuthenticationStateAsync(string id) + public async Task> GetAuthenticationStateAsync(string id) { - throw new NotImplementedException(); + var item = MemoryData.AuthenticationStates.FirstOrDefault(x => x.Id == id); + if (item == null) return Result.Failed("Could not get the session state!"); + return Result.Ok(item); } +} + +internal static class MemoryData +{ + public static readonly List users = []; + public static readonly List Groups = []; + public static readonly List AuthenticationStates = []; } \ No newline at end of file diff --git a/DotBased.ASP.Auth/Scheme/BasedAuthenticationHandler.cs b/DotBased.ASP.Auth/Scheme/BasedAuthenticationHandler.cs deleted file mode 100644 index aaca335..0000000 --- a/DotBased.ASP.Auth/Scheme/BasedAuthenticationHandler.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Security.Claims; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace DotBased.ASP.Auth.Scheme; - -// Handles if a user is logged in -public class BasedAuthenticationHandler : AuthenticationHandler -{ - public const string AuthenticationScheme = "DotBasedAuthentication"; - -#pragma warning disable CS0618 // Type or member is obsolete - public BasedAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) -#pragma warning restore CS0618 // Type or member is obsolete - { - - } - - public BasedAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) - { - - } - - protected override Task HandleAuthenticateAsync() - { - /*var principal = new ClaimsPrincipal(new ClaimsIdentity());*/ - var principal = new ClaimsPrincipal(new ClaimsIdentity(new List() { new Claim(ClaimTypes.Role, "Admin"), new Claim(ClaimTypes.Name, "Anon") }, AuthenticationScheme)); - var ticket = new AuthenticationTicket(principal, AuthenticationScheme); - return Task.FromResult(AuthenticateResult.Success(ticket)); - /*return AuthenticateResult.Fail("No login found!");*/ - } -} \ No newline at end of file diff --git a/DotBased.ASP.Auth/Scheme/BasedAuthenticationHandlerOptions.cs b/DotBased.ASP.Auth/Scheme/BasedAuthenticationHandlerOptions.cs deleted file mode 100644 index 3f8228c..0000000 --- a/DotBased.ASP.Auth/Scheme/BasedAuthenticationHandlerOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.AspNetCore.Authentication; - -namespace DotBased.ASP.Auth.Scheme; - -public class BasedAuthenticationHandlerOptions : AuthenticationSchemeOptions -{ - -} \ No newline at end of file diff --git a/DotBased.ASP.Auth/Services/AuthService.cs b/DotBased.ASP.Auth/Services/AuthService.cs deleted file mode 100644 index 74e8c1a..0000000 --- a/DotBased.ASP.Auth/Services/AuthService.cs +++ /dev/null @@ -1,67 +0,0 @@ -using DotBased.ASP.Auth.Domains; -using DotBased.ASP.Auth.Domains.Auth; -using DotBased.ASP.Auth.Domains.Identity; -using DotBased.Extensions; -using DotBased.Logging; - -namespace DotBased.ASP.Auth.Services; - -public class AuthService -{ - public AuthService(IAuthDataRepository authDataRepository, AuthDataCache dataCache) - { - _authDataRepository = authDataRepository; - _dataCache = dataCache; - _logger = LogService.RegisterLogger(typeof(AuthService)); - } - - private readonly IAuthDataRepository _authDataRepository; - private readonly AuthDataCache _dataCache; - private readonly ILogger _logger; - - public async Task> LoginAsync(LoginModel login) - { - UserModel? user = null; - Result usrResult; - if (!login.UserName.IsNullOrWhiteSpace()) - { - usrResult = await _authDataRepository.GetUserAsync(string.Empty, string.Empty, login.UserName); - if (usrResult is { Success: true, Value: not null }) - user = usrResult.Value; - } - else if (!login.Email.IsNullOrWhiteSpace()) - { - usrResult = await _authDataRepository.GetUserAsync(string.Empty, login.Email, string.Empty); - if (usrResult is { Success: true, Value: not null }) - user = usrResult.Value; - } - else - return Result.Failed("Username & Email is empty, cannot login!"); - - if (user == null || !usrResult.Success) - return new Result(usrResult); - - if (user.PasswordHash != login.Password) //TODO: Hash password and compare - return Result.Failed("Login failed, invalid password."); - var state = new AuthenticationStateModel(user); - await _authDataRepository.CreateAuthenticationStateAsync(state); - return Result.Ok(state); - } - - public async Task Logout(string state) - { - if (state.IsNullOrWhiteSpace()) - return Result.Failed($"Argument {nameof(state)} is empty!"); - /*var stateResult = await _dataProvider.GetAuthenticationStateAsync(state); - if (!stateResult.Success || stateResult.Value == null) - return stateResult; - var authState = stateResult.Value; - //TODO: Update state to logged out and update the state - - var updatedStateResult = await _dataProvider.UpdateAuthenticationStateAsync(authState); - if (updatedStateResult.Success) return updatedStateResult; - _logger.Warning(updatedStateResult.Message); - return updatedStateResult;*/ - return Result.Failed($"Argument {nameof(state)} is empty!"); // <- TEMP - } -} \ No newline at end of file diff --git a/DotBased.ASP.Auth/Services/SecurityService.cs b/DotBased.ASP.Auth/Services/SecurityService.cs new file mode 100644 index 0000000..57a5f74 --- /dev/null +++ b/DotBased.ASP.Auth/Services/SecurityService.cs @@ -0,0 +1,122 @@ +using System.Security.Claims; +using DotBased.ASP.Auth.Domains; +using DotBased.ASP.Auth.Domains.Auth; +using DotBased.ASP.Auth.Domains.Identity; +using DotBased.Extensions; +using DotBased.Logging; +using Microsoft.AspNetCore.Components.Authorization; + +namespace DotBased.ASP.Auth.Services; + +public class SecurityService +{ + public SecurityService(IAuthDataRepository authDataRepository, AuthDataCache dataCache) + { + _authDataRepository = authDataRepository; + _dataCache = dataCache; + _logger = LogService.RegisterLogger(typeof(SecurityService)); + } + + private readonly IAuthDataRepository _authDataRepository; + private readonly AuthDataCache _dataCache; + private readonly ILogger _logger; + + public async Task> GetAuthenticationFromSession(string id) + { + if (id.IsNullOrWhiteSpace()) + return Result.Failed("No valid id!"); + AuthenticationStateModel? authState = null; + var stateCache = _dataCache.RequestSessionState(id); + if (!stateCache.Success || stateCache.Value == null) + { + var stateResult = await _authDataRepository.GetAuthenticationStateAsync(id); + if (stateResult is { Success: true, Value: not null }) + authState = stateResult.Value; + } + else + authState = stateCache.Value; + + if (authState == null) + return Result.Failed("Failed to get state!"); + + var userResult = await _authDataRepository.GetUserAsync(authState.UserId, string.Empty, string.Empty); + if (userResult is not { Success: true, Value: not null }) + return Result.Failed("Failed to get user from state!"); + var claims = new List() + { + new(ClaimTypes.Name, userResult.Value.Name), + new(ClaimTypes.NameIdentifier, userResult.Value.UserName), + new(ClaimTypes.Surname, userResult.Value.FamilyName), + new(ClaimTypes.Email, userResult.Value.Email) + }; + claims.AddRange(userResult.Value.Roles.Select(role => new Claim(ClaimTypes.Role, role.Name)).ToList()); + var claimsIdentity = new ClaimsIdentity(claims, BasedAuthDefaults.AuthenticationScheme); + var auth = new AuthenticationState(new ClaimsPrincipal(claimsIdentity)); + return Result.Ok(auth); + } + + public async Task> LoginAsync(LoginModel login) + { + try + { + UserModel? user = null; + Result usrResult; + if (!login.UserName.IsNullOrWhiteSpace()) + { + usrResult = await _authDataRepository.GetUserAsync(string.Empty, string.Empty, login.UserName); + if (usrResult is { Success: true, Value: not null }) + user = usrResult.Value; + } + else if (!login.Email.IsNullOrWhiteSpace()) + { + usrResult = await _authDataRepository.GetUserAsync(string.Empty, login.Email, string.Empty); + if (usrResult is { Success: true, Value: not null }) + user = usrResult.Value; + } + else + return Result.Failed("Username & Email is empty, cannot login!"); + + if (user == null || !usrResult.Success) + return Result.Failed("No user found!"); + + if (user.PasswordHash != login.Password) //TODO: Hash password and compare + return Result.Failed("Login failed, invalid password."); + var state = new AuthenticationStateModel(user); + var authResult = await _authDataRepository.CreateAuthenticationStateAsync(state); + if (!authResult.Success) + return Result.Failed("Failed to store session to database!"); + _dataCache.CacheSessionState(state); + return Result.Ok(state); + } + catch (Exception e) + { + _logger.Error(e, "Failed to login!"); + return Result.Failed("Login failed, exception thrown!"); + } + } + + public async Task LogoutAsync(string state) + { + try + { + if (state.IsNullOrWhiteSpace()) + return Result.Failed($"Argument {nameof(state)} is empty!"); + + var stateResult = await _authDataRepository.GetAuthenticationStateAsync(state); + if (!stateResult.Success || stateResult.Value == null) + return stateResult; + var authState = stateResult.Value; + + _dataCache.PurgeSessionState(state); + var updatedStateResult = await _authDataRepository.DeleteAuthenticationStateAsync(authState); + if (updatedStateResult.Success) return updatedStateResult; + _logger.Warning(updatedStateResult.Message); + return updatedStateResult; + } + catch (Exception e) + { + _logger.Error(e, "Failed to logout!"); + return Result.Failed("Failed to logout, exception thrown!"); + } + } +} \ No newline at end of file