From 5d697fbcefd51306fc4b9fc9c7bb2db6dfb51bb8 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sun, 9 Nov 2025 15:18:08 +0100 Subject: [PATCH] (feature): login, refresh and logout --- README.md | 1 + src/LetsEncryptServer/Configuration.cs | 1 + .../Controllers/IdentityController.cs | 34 ++--- src/LetsEncryptServer/Domain/JwtToken.cs | 65 ++++++++ src/LetsEncryptServer/Domain/Settings.cs | 57 ++++++- src/LetsEncryptServer/Domain/User.cs | 144 +++++++++++++++--- src/LetsEncryptServer/Dto/JwtTokenDto.cs | 12 ++ src/LetsEncryptServer/Dto/SettingsDto.cs | 1 + src/LetsEncryptServer/Dto/UserDto.cs | 15 +- .../Services/CertsFlowService.cs | 32 +++- .../Services/IdentityService.cs | 142 +++++++++++++---- .../Services/SettingsService.cs | 73 ++++++--- src/LetsEncryptServer/appsettings.json | 1 + .../models/identity/logout/LogoutRequest.ts | 8 +- .../src/redux/slices/identitySlice.ts | 7 +- .../Identity/Logout/LogoutRequest.cs | 1 + 16 files changed, 478 insertions(+), 116 deletions(-) create mode 100644 src/LetsEncryptServer/Domain/JwtToken.cs create mode 100644 src/LetsEncryptServer/Dto/JwtTokenDto.cs diff --git a/README.md b/README.md index 772e5b6..eb1fa1e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Simple client to obtain Let's Encrypt HTTPS certificates developed with .net cor * 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation) * 31 May, 2024 - V3.0 (Webapi and containerization) * 11 Aug, 2024 - V3.1 (Release) +* 11 Sep, 2025 - V3.2 New WebUI with authentication ## Haproxy configuration diff --git a/src/LetsEncryptServer/Configuration.cs b/src/LetsEncryptServer/Configuration.cs index 224048a..c6e3832 100644 --- a/src/LetsEncryptServer/Configuration.cs +++ b/src/LetsEncryptServer/Configuration.cs @@ -34,6 +34,7 @@ namespace MaksIT.LetsEncryptServer { public required string CacheFolder { get; set; } public required string AcmeFolder { get; set; } + public required string DataFolder { get; set; } public required Agent Agent { get; set; } } diff --git a/src/LetsEncryptServer/Controllers/IdentityController.cs b/src/LetsEncryptServer/Controllers/IdentityController.cs index c00a65c..a56a9b3 100644 --- a/src/LetsEncryptServer/Controllers/IdentityController.cs +++ b/src/LetsEncryptServer/Controllers/IdentityController.cs @@ -22,28 +22,18 @@ public class IdentityController( return result.ToActionResult(); } - //[HttpPost("refresh")] - //[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] - //public async Task RefreshToken([FromBody] RefreshTokenRequest requestData) { - // var result = await _identityService.RefreshTokenAsync(requestData); - // return result.ToActionResult(); - //} + [HttpPost("refresh")] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + public async Task RefreshToken([FromBody] RefreshTokenRequest requestData) { + var result = await _identityService.RefreshTokenAsync(requestData); + return result.ToActionResult(); + } - //[ServiceFilter(typeof(JwtAuthorizationFilter))] - //[HttpPost("logout")] - //[ProducesResponseType(StatusCodes.Status200OK)] - //public async Task Logout([FromBody] LogoutRequest requetData) { - // var jwtTokenDataResult = HttpContext.GetJwtTokenData(); - // if (!jwtTokenDataResult.IsSuccess || jwtTokenDataResult.Value == null) - // return jwtTokenDataResult.ToActionResult(); - - // var jwtTokenData = jwtTokenDataResult.Value; - - // var result = await _identityService.Logout(jwtTokenData, requetData); - // return result.ToActionResult(); - //} + [HttpPost("logout")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Logout([FromBody] LogoutRequest requestData) { + var result = await _identityService.Logout(requestData); + return result.ToActionResult(); + } #endregion - - - } diff --git a/src/LetsEncryptServer/Domain/JwtToken.cs b/src/LetsEncryptServer/Domain/JwtToken.cs new file mode 100644 index 0000000..7d6ea76 --- /dev/null +++ b/src/LetsEncryptServer/Domain/JwtToken.cs @@ -0,0 +1,65 @@ +using MaksIT.Core.Abstractions.Domain; +using System.Linq.Dynamic.Core.Tokenizer; + +namespace MaksIT.LetsEncryptServer.Domain; + +public class JwtToken(Guid id) : DomainDocumentBase(id) { + + + /// + /// Represents a JSON Web Token (JWT) used for authentication and authorization. + /// + public string TokenType { get; private set; } = "Bearer"; + + /// + /// The actual JWT token string. + /// + public string Token { get; private set; } = string.Empty; + + /// + /// The date and time when the JWT was issued. + /// + public DateTime IssuedAt { get; private set; } + + /// + /// The date and time when the JWT will expire. + /// + public DateTime ExpiresAt { get; private set; } + + /// + /// Indicates whether the JWT has been revoked. + /// + public bool IsRevoked { get; private set; } = false; + + /// + /// The refresh token associated with this JWT, used to obtain a new JWT when the current one expires. + /// + public string RefreshToken { get; private set; } = string.Empty; + + /// + /// The date and time when the refresh token will expire. + /// + public DateTime RefreshTokenExpiresAt { get; private set; } + + public JwtToken() : this(Guid.NewGuid()) { } + + public JwtToken SetAccessTokenData( + string token, + DateTime issuedAt, + DateTime expiresAt + ) { + Token = token; + IssuedAt = issuedAt; + ExpiresAt = expiresAt; + return this; + } + + public JwtToken SetRefreshTokenData( + string refreshToken, + DateTime refreshTokenExpiresAt + ) { + RefreshToken = refreshToken; + RefreshTokenExpiresAt = refreshTokenExpiresAt; + return this; + } +} diff --git a/src/LetsEncryptServer/Domain/Settings.cs b/src/LetsEncryptServer/Domain/Settings.cs index dfb7d71..00f9bd4 100644 --- a/src/LetsEncryptServer/Domain/Settings.cs +++ b/src/LetsEncryptServer/Domain/Settings.cs @@ -8,7 +8,7 @@ public class Settings : DomainObjectBase { public bool Init { get; set; } public List Users { get; set; } = []; - public Settings() {} + public Settings() { } public Result Initialize(string pepper) { var userResult = new User("admin") @@ -25,35 +25,80 @@ public class Settings : DomainObjectBase { } public Result GetUserByName(string name) { - var user = Users.FirstOrDefault(x => x.Name == name); - if (user == null) return Result.NotFound(null, "User not found."); return Result.Ok(user); } + public Result GetByJwtToken(string token) { + var user = Users.FirstOrDefault(u => u.JwtTokens.Any(t => t.Token == token)); + if (user == null) + return Result.NotFound(null, "User not found."); + return Result.Ok(user); + } + + public Result GetByRefreshToken(string refreshToken) { + var user = Users.FirstOrDefault(u => u.JwtTokens.Any(t => t.RefreshToken == refreshToken)); + if (user == null) + return Result.NotFound(null, "User not found for the provided refresh token."); + + return Result.Ok(user); + } + public Result AddUser(string name, string password, string pepper) { var setPasswordResult = new User(name) .SetPassword(password, pepper); if (!setPasswordResult.IsSuccess || setPasswordResult.Value == null) return setPasswordResult.ToResultOfType(_ => null); - + var user = setPasswordResult.Value; Users.Add(user); - + return Result.Ok(this); } + public Settings UpsertUser(User user) { + var existing = Users.FirstOrDefault(u => u.Id == user.Id); + if (existing != null) + Users.Remove(existing); + Users.Add(user); + return this; + } + + public Settings UpsertUsers(List users) { + foreach (var user in users) + UpsertUser(user); + return this; + } + + public Result RemoveUser(string name) { if (Users.Any(x => x.Name == name)) { Users = [.. Users.Where(u => u.Name != name)]; return Result.Ok(this); } - + return Result.NotFound(null, "User not found."); } + + public Result RemoveUser(Guid userId) { + var user = Users.FirstOrDefault(u => u.Id == userId); + if (user == null) + return Result.NotFound(null, "User not found."); + Users.Remove(user); + return Result.Ok(this); + } + + public Result RemoveUsers(List userIds) { + foreach (var userId in userIds) { + var removeResult = RemoveUser(userId); + if (!removeResult.IsSuccess) + return removeResult; + } + return Result.Ok(this); + } } \ No newline at end of file diff --git a/src/LetsEncryptServer/Domain/User.cs b/src/LetsEncryptServer/Domain/User.cs index 2e93e81..ce263ef 100644 --- a/src/LetsEncryptServer/Domain/User.cs +++ b/src/LetsEncryptServer/Domain/User.cs @@ -7,10 +7,12 @@ namespace MaksIT.LetsEncryptServer.Domain; public class User( Guid id, string name - ) : DomainDocumentBase(id) { +) : DomainDocumentBase(id) { public string Name { get; private set; } = name; public string Salt { get; private set; } = string.Empty; public string Hash { get; private set; } = string.Empty; + public List JwtTokens { get; private set; } = []; + public DateTime LastLogin { get; private set; } public User( string name @@ -19,8 +21,57 @@ public class User( name ) { } + /// + /// Change user name + /// + /// + /// + /// + public User SetName(string newName) { + Name = newName; + return this; + } - // Set or change password (returns this for chaining) + /// + /// For persistence + /// + /// + /// + /// + public User SetSaltedHash(string salt, string hash) { + Salt = salt; + Hash = hash; + + return this; + } + + /// + /// + /// + /// + /// + public User SetJwtTokens(List tokens) { + JwtTokens = tokens; + return this; + } + + public User SetLastLogin() { + SetLastLogin(DateTime.UtcNow); + return this; + } + + public User SetLastLogin(DateTime dateTime) { + LastLogin = dateTime; + return this; + } + + #region Password Management + /// + /// Set or change password (returns this for chaining) + /// + /// + /// + /// public Result SetPassword(string password, string pepper) { if (!PasswordHasher.TryCreateSaltedHash(password, pepper, out var saltedHash, out var errorMessage)) return Result.InternalServerError(null, errorMessage); @@ -31,18 +82,12 @@ public class User( return Result.Ok(this); } - // Reset password to a new value (returns this for chaining) - public Result ResetPassword(string newPassword, string pepper) => SetPassword(newPassword, pepper); - - // Change user name - public User ChangeName(string newName) { - if (string.IsNullOrWhiteSpace(newName)) - throw new ArgumentException("Name cannot be empty.", nameof(newName)); - Name = newName; - return this; - } - - // Validate password + /// + /// Validate password + /// + /// + /// + /// public Result ValidatePassword(string password, string pepper) { if (PasswordHasher.TryValidateHash(password, Salt, Hash, pepper, out var isValid, out var errorMessage)) { if (isValid) @@ -54,11 +99,72 @@ public class User( return Result.InternalServerError(null, errorMessage); } - // For persistence - public User SeltSaltedHash(string salt, string hash) { - Salt = salt; - Hash = hash; + /// + /// Reset password to a new value (returns this for chaining) + /// + /// + /// + /// + public Result ResetPassword(string newPassword, string pepper) => SetPassword(newPassword, pepper); + #endregion + + #region JWT Token Management + public User UpsertJwtToken(JwtToken token) { + var existing = JwtTokens.FirstOrDefault(t => t.Id == token.Id); + + if (existing != null) + JwtTokens.Remove(existing); + + JwtTokens.Add(token); return this; } -} \ No newline at end of file + + public User UpsertJwtTokens(List tokens) { + foreach (var token in tokens) + UpsertJwtToken(token); + + return this; + } + + public Result RemoveJwtToken(Guid tokenId) { + var token = JwtTokens.FirstOrDefault(t => t.Id == tokenId); + if (token == null) + return Result.NotFound(null, "JWT token not found."); + + JwtTokens.Remove(token); + return Result.Ok(this); + } + + public Result RemoveJwtToken(string token) { + var tokenDomain = JwtTokens.FirstOrDefault(t => t.Token == token); + if (tokenDomain == null) + return Result.NotFound(null, "JWT token not found."); + JwtTokens.Remove(tokenDomain); + return Result.Ok(this); + } + + public Result RemoveJwtTokens(List tokenIds) { + + foreach (var tokenId in tokenIds) { + var removeTokenResult = RemoveJwtToken(tokenId); + + if (!removeTokenResult.IsSuccess) + return removeTokenResult; + } + + return Result.Ok(this); + } + + public User RemoveRevokedJwtTokens() { + JwtTokens = JwtTokens.Where(t => !t.IsRevoked).ToList(); + return this; + } + + public User RevokeAllJwtTokens() { + JwtTokens = []; + return this; + } + + #endregion +} diff --git a/src/LetsEncryptServer/Dto/JwtTokenDto.cs b/src/LetsEncryptServer/Dto/JwtTokenDto.cs new file mode 100644 index 0000000..d598d50 --- /dev/null +++ b/src/LetsEncryptServer/Dto/JwtTokenDto.cs @@ -0,0 +1,12 @@ +using MaksIT.Core.Abstractions.Dto; + +namespace MaksIT.LetsEncryptServer.Dto; + +public class JwtTokenDto : DtoDocumentBase { + public required string Token { get; set; } + public DateTime IssuedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public required string RefreshToken { get; set; } + public DateTime RefreshTokenExpiresAt { get; set; } + public bool IsRevoked { get; set; } = false; +} diff --git a/src/LetsEncryptServer/Dto/SettingsDto.cs b/src/LetsEncryptServer/Dto/SettingsDto.cs index 95709aa..00b9111 100644 --- a/src/LetsEncryptServer/Dto/SettingsDto.cs +++ b/src/LetsEncryptServer/Dto/SettingsDto.cs @@ -3,4 +3,5 @@ public class SettingsDto { public required bool Init { get; set; } public required List Users { get; set; } = []; + } \ No newline at end of file diff --git a/src/LetsEncryptServer/Dto/UserDto.cs b/src/LetsEncryptServer/Dto/UserDto.cs index d7a0399..f4132e7 100644 --- a/src/LetsEncryptServer/Dto/UserDto.cs +++ b/src/LetsEncryptServer/Dto/UserDto.cs @@ -1,8 +1,11 @@ -namespace MaksIT.LetsEncryptServer.Dto; +using MaksIT.Core.Abstractions.Dto; -public class UserDto { - public required string Id { get; set; } - public required string Name { get; set; } - public required string Salt { get; set; } - public required string Hash { get; set; } +namespace MaksIT.LetsEncryptServer.Dto; + +public class UserDto : DtoDocumentBase { + public required string Name { get; set; } = string.Empty; + public required string Salt { get; set; } = string.Empty; + public required string Hash { get; set; } = string.Empty; + public required List JwtTokens { get; set; } = []; + public required DateTime LastLogin { get; set; } } diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 68c3643..7fb7edc 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -56,15 +56,33 @@ public class CertsFlowService : ICertsFlowService { var termsOfServiceUrl = result.Value; try { - var pdfBytesTask = _httpClient.GetByteArrayAsync(termsOfServiceUrl); - pdfBytesTask.Wait(); - var pdfBytes = pdfBytesTask.Result; - var base64 = Convert.ToBase64String(pdfBytes); - return Result.Ok(base64); + var fileName = Path.GetFileName(new Uri(termsOfServiceUrl).LocalPath); + + var termsOfServicePdfPath = Path.Combine(_appSettings.DataFolder, fileName); + + // Clean up old PDF files except the current one + foreach (var file in Directory.GetFiles(_appSettings.DataFolder, "*.pdf")) { + if (!string.Equals(Path.GetFileName(file), fileName, StringComparison.OrdinalIgnoreCase)) { + try { + File.Delete(file); + } + catch { /* ignore */ } + } + } + + byte[] pdfBytes; + if (File.Exists(termsOfServicePdfPath)) { + pdfBytes = File.ReadAllBytes(termsOfServicePdfPath); + } else { + pdfBytes = _httpClient.GetByteArrayAsync(termsOfServiceUrl).GetAwaiter().GetResult(); + File.WriteAllBytes(termsOfServicePdfPath, pdfBytes); + } + var base64 = Convert.ToBase64String(pdfBytes); + return Result.Ok(base64); } catch (Exception ex) { - _logger.LogError(ex, "Failed to download or convert Terms of Service PDF"); - return Result.InternalServerError(null, $"Failed to download or convert Terms of Service PDF: {ex.Message}"); + _logger.LogError(ex, "Failed to download, cache, or convert Terms of Service PDF"); + return Result.InternalServerError(null, $"Failed to download, cache, or convert Terms of Service PDF: {ex.Message}"); } } diff --git a/src/LetsEncryptServer/Services/IdentityService.cs b/src/LetsEncryptServer/Services/IdentityService.cs index 2acaeca..e85da83 100644 --- a/src/LetsEncryptServer/Services/IdentityService.cs +++ b/src/LetsEncryptServer/Services/IdentityService.cs @@ -4,6 +4,7 @@ using MaksIT.Results; using Microsoft.Extensions.Options; using Models.LetsEncryptServer.Identity.Login; using Models.LetsEncryptServer.Identity.Logout; +using System.Linq.Dynamic.Core.Tokenizer; using System.Security.Claims; namespace MaksIT.LetsEncryptServer.Services; @@ -12,8 +13,8 @@ namespace MaksIT.LetsEncryptServer.Services; public interface IIdentityService { #region Login/Refresh/Logout Task> LoginAsync(LoginRequest requestData); - //Task> RefreshTokenAsync(RefreshTokenRequest requestData); - //Task Logout(JwtTokenData jwtTokenData, LogoutRequest requestData); + Task> RefreshTokenAsync(RefreshTokenRequest requestData); + Task Logout(LogoutRequest requestData); #endregion } @@ -64,45 +65,132 @@ public class IdentityService( string refreshToken = JwtGenerator.GenerateRefreshToken(); + var tokenDomain = new JwtToken() + .SetAccessTokenData(token, claims.IssuedAt.Value, claims.ExpiresAt.Value) + .SetRefreshTokenData(refreshToken, claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration)); + + user.UpsertJwtToken(tokenDomain); + user.SetLastLogin(); + settings.UpsertUser(user); + + var saveSettingsResult = await _settingsService.SaveAsync(settings); + if (!saveSettingsResult.IsSuccess) + return saveSettingsResult.ToResultOfType(default); + var response = new LoginResponse { - TokenType = "Bearer", - Token = token, + TokenType = tokenDomain.TokenType, + Token = tokenDomain.Token, ExpiresAt = claims.ExpiresAt.Value, - RefreshToken = refreshToken, - RefreshTokenExpiresAt = claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration) + RefreshToken = tokenDomain.RefreshToken, + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt }; return Result.Ok(response); } + public async Task> RefreshTokenAsync(RefreshTokenRequest requestData) { + var loadSettingsResult = await _settingsService.LoadAsync(); + if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) + return loadSettingsResult.ToResultOfType(_ => null); + var settings = loadSettingsResult.Value; + var userResult = settings.GetByRefreshToken(requestData.RefreshToken); + if (!userResult.IsSuccess || userResult.Value == null) + return Result.Unauthorized(null, "Invalid refresh token."); + var user = userResult.Value.RemoveRevokedJwtTokens(); + var tokenDomain = user.JwtTokens.SingleOrDefault(t => t.RefreshToken == requestData.RefreshToken); - //public async Task> RefreshTokenAsync(RefreshTokenRequest requestData) { - // return await HandleTokenResponseAsync(() => - // _identityDomainService.RefreshTokenAsync(requestData.RefreshToken)); - //} + if (tokenDomain == null) + return Result.Unauthorized(null, "Invalid refresh token."); - //private static async Task> HandleTokenResponseAsync(Func>> tokenOperation) { - // var jwtTokenResult = await tokenOperation(); - // if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null) - // return jwtTokenResult.ToResultOfType(_ => null); + // Token is still valid + if (DateTime.UtcNow <= tokenDomain.ExpiresAt) { + user.SetLastLogin(); + settings.UpsertUser(user); - // var jwtToken = jwtTokenResult.Value; + var saveResult = await _settingsService.SaveAsync(settings); + if (!saveResult.IsSuccess) + return saveResult.ToResultOfType(default); - // return Result.Ok(new LoginResponse { - // TokenType = jwtToken.TokenType, - // Token = jwtToken.Token, - // ExpiresAt = jwtToken.ExpiresAt, - // RefreshToken = jwtToken.RefreshToken, - // RefreshTokenExpiresAt = jwtToken.RefreshTokenExpiresAt - // }); - //} + return Result.Ok(new LoginResponse { + TokenType = tokenDomain.TokenType, + Token = tokenDomain.Token, + ExpiresAt = tokenDomain.ExpiresAt, + RefreshToken = tokenDomain.RefreshToken, + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt + }); + } - //public async Task Logout(JwtTokenData jwtTokenData, LogoutRequest requestData) { - // var logoutResult = await _identityDomainService.LogoutAsync(jwtTokenData.Username, jwtTokenData.Token, requestData.LogoutFromAllDevices); - // return logoutResult; - //} + // Refresh token expired + if (DateTime.UtcNow > tokenDomain.RefreshTokenExpiresAt) { + user.RemoveJwtToken(tokenDomain.Id); + return Result.Unauthorized(null, "Refresh token has expired."); + } + + // Refresh token is valid - generate new tokens + + if (!JwtGenerator.TryGenerateToken(new JWTTokenGenerateRequest { + Secret = _appSettings.Auth.Secret, + Issuer = _appSettings.Auth.Issuer, + Audience = _appSettings.Auth.Audience, + Expiration = _appSettings.Auth.Expiration, + UserId = user.Id.ToString(), + Username = user.Name, + }, out (string token, JWTTokenClaims claims)? tokenData, out string? errorMessage)) + return Result.InternalServerError(null, errorMessage); + + var (token, claims) = tokenData.Value; + + if (claims.IssuedAt == null || claims.ExpiresAt == null) + return Result.InternalServerError(null, "Token claims are missing required fields."); + + string refreshToken = JwtGenerator.GenerateRefreshToken(); + + tokenDomain = new JwtToken() + .SetAccessTokenData(token, claims.IssuedAt.Value, claims.ExpiresAt.Value) + .SetRefreshTokenData(refreshToken, claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration)); + + user.UpsertJwtToken(tokenDomain); + user.SetLastLogin(); + settings.UpsertUser(user); + + var writeResult = await _settingsService.SaveAsync(settings); + if (!writeResult.IsSuccess) + return writeResult.ToResultOfType(default); + + return Result.Ok(new LoginResponse { + TokenType = tokenDomain.TokenType, + Token = tokenDomain.Token, + ExpiresAt = claims.ExpiresAt.Value, + RefreshToken = tokenDomain.RefreshToken, + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt + }); + } + + public async Task Logout(LogoutRequest requestData) { + var loadSettingsResult = await _settingsService.LoadAsync(); + if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) + return loadSettingsResult.ToResultOfType(_ => null); + + var settings = loadSettingsResult.Value; + + var userResult = settings.GetByJwtToken(requestData.Token); + if (userResult.IsSuccess && userResult.Value != null) { + var user = userResult.Value; + + if (requestData.LogoutFromAllDevices) + user.RevokeAllJwtTokens(); + else + user.RemoveJwtToken(requestData.Token); + + settings.UpsertUser(user); + + var writeUserResult = await settingsService.SaveAsync(settings); + } + + return Result.Ok(); + } #endregion } diff --git a/src/LetsEncryptServer/Services/SettingsService.cs b/src/LetsEncryptServer/Services/SettingsService.cs index 9d11887..2385f98 100644 --- a/src/LetsEncryptServer/Services/SettingsService.cs +++ b/src/LetsEncryptServer/Services/SettingsService.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; namespace MaksIT.LetsEncryptServer.Services; public interface ISettingsService { - Task> LoadAsync(); + Task> LoadAsync(); Task SaveAsync(Settings settings); } @@ -31,50 +31,73 @@ public class SettingsService : ISettingsService, IDisposable { #region Internal I/O - private async Task> LoadInternalAsync() { + private async Task> LoadInternalAsync() { try { if (!File.Exists(_settingsPath)) - return Result.Ok(new Settings()); + return Result.Ok(new Settings()); var json = await File.ReadAllTextAsync(_settingsPath); var settingsDto = json.ToObject(); if (settingsDto == null) - return Result.InternalServerError(new Settings(), "Settings file is invalid or empty."); + return Result.InternalServerError(new Settings(), "Settings file is invalid or empty."); var settings = new Settings { Init = settingsDto.Init, - Users = [.. settingsDto.Users.Select(userDto => new User(userDto.Id.ToGuid(), userDto.Name).SeltSaltedHash(userDto.Salt, userDto.Hash))] + Users = [.. settingsDto.Users.Select(userDto => new User(userDto.Id, userDto.Name) + .SetSaltedHash(userDto.Salt, userDto.Hash) + .SetJwtTokens([.. userDto.JwtTokens.Select(jtDto => + new JwtToken(jtDto.Id) + .SetAccessTokenData(jtDto.Token, jtDto.IssuedAt, jtDto.ExpiresAt) + .SetRefreshTokenData(jtDto.RefreshToken, jtDto.RefreshTokenExpiresAt) + )]) + .SetLastLogin(userDto.LastLogin) + )] }; - return Result.Ok(settings); - } catch (Exception ex) { - _logger.LogError(ex, "Error loading settings file."); - return Result.InternalServerError(new Settings(), ex.Message); + return Result.Ok(settings); + } + catch (Exception ex) { + var message = "Error loading settings file."; + _logger.LogError(ex, message); + return Result.InternalServerError(null, new[] { message }.Concat(ex.ExtractMessages()).ToArray()); } } private async Task SaveInternalAsync(Settings settings) { try { - var settingsDto = new SettingsDto { - Init = settings.Init, - Users = [.. settings.Users.Select(u => new UserDto { - Id = u.Id.ToString(), - Name = u.Name, - Salt = u.Salt, - Hash = u.Hash - })] - }; - await File.WriteAllTextAsync(_settingsPath, settingsDto.ToJson()); - _logger.LogInformation("Settings file saved."); - return Result.Ok(); - } catch (Exception ex) { - _logger.LogError(ex, "Error saving settings file."); - return Result.InternalServerError(ex.Message); + var settingsDto = new SettingsDto { + Init = settings.Init, + Users = [.. settings.Users.Select(u => new UserDto { + Id = u.Id, + Name = u.Name, + Salt = u.Salt, + Hash = u.Hash, + JwtTokens = [.. u.JwtTokens.Select(jt => new JwtTokenDto { + Id = jt.Id, + Token = jt.Token, + ExpiresAt = jt.ExpiresAt, + IssuedAt = jt.IssuedAt, + RefreshToken = jt.RefreshToken, + RefreshTokenExpiresAt = jt.RefreshTokenExpiresAt, + IsRevoked = jt.IsRevoked + })], + LastLogin = u.LastLogin, + })] + }; + + await File.WriteAllTextAsync(_settingsPath, settingsDto.ToJson()); + _logger.LogInformation("Settings file saved."); + return Result.Ok(); + } + catch (Exception ex) { + var message = "Error saving settings file."; + _logger.LogError(ex, message); + return Result.InternalServerError([message, .. ex.ExtractMessages()]); } } #endregion - public async Task> LoadAsync() { + public async Task> LoadAsync() { return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync()); } diff --git a/src/LetsEncryptServer/appsettings.json b/src/LetsEncryptServer/appsettings.json index 8bc7988..b88ded1 100644 --- a/src/LetsEncryptServer/appsettings.json +++ b/src/LetsEncryptServer/appsettings.json @@ -25,6 +25,7 @@ "CacheFolder": "/cache", "AcmeFolder": "/acme", + "DataFolder": "/data", "Agent": { "AgentHostname": "", diff --git a/src/MaksIT.WebUI/src/models/identity/logout/LogoutRequest.ts b/src/MaksIT.WebUI/src/models/identity/logout/LogoutRequest.ts index 1fb1593..e05a4af 100644 --- a/src/MaksIT.WebUI/src/models/identity/logout/LogoutRequest.ts +++ b/src/MaksIT.WebUI/src/models/identity/logout/LogoutRequest.ts @@ -1,9 +1,11 @@ -import { z } from 'zod' +import { boolean, object, Schema, string } from 'zod' export interface LogoutRequest { logOutFromAllDevices?: boolean; + token: string; } -export const LoginRequestSchema: z.Schema = z.object({ - logOutFromAllDevices: z.boolean().optional() +export const LoginRequestSchema: Schema = object({ + logOutFromAllDevices: boolean().optional(), + token: string() }) \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts b/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts index 4eeadc0..6dae1db 100644 --- a/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts +++ b/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts @@ -55,9 +55,14 @@ const login = createAsyncThunk( const logout = createAsyncThunk( 'auth/logout', async (logOutFromAllDevices: boolean = false) => { + const identity = readIdentity() + if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) + return + const apiRoute = GetApiRoute(ApiRoutes.identityLogout) const response = await postData(apiRoute.route, { - logOutFromAllDevices + logOutFromAllDevices, + token: identity.token }) return response } diff --git a/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs b/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs index a7a7a67..c8b79e0 100644 --- a/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs +++ b/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs @@ -4,5 +4,6 @@ namespace Models.LetsEncryptServer.Identity.Logout; public class LogoutRequest : RequestModelBase { + public required string Token { get; set; } public bool LogoutFromAllDevices { get; set; } }