diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj index c472a56..a87467b 100644 --- a/src/LetsEncrypt/LetsEncrypt.csproj +++ b/src/LetsEncrypt/LetsEncrypt.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/LetsEncryptServer/BackgroundServices/Initialization.cs b/src/LetsEncryptServer/BackgroundServices/Initialization.cs new file mode 100644 index 0000000..fb9e696 --- /dev/null +++ b/src/LetsEncryptServer/BackgroundServices/Initialization.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Options; +using MaksIT.LetsEncryptServer.Domain; +using MaksIT.LetsEncryptServer.Services; + + +namespace MaksIT.LetsEncryptServer.BackgroundServices { + + public class Initialization : BackgroundService { + private readonly IServiceProvider _serviceProvider; + private readonly Configuration _appSettings; + private readonly ISettingsService _settingsService; + + public Initialization( + IOptions appSettings, + IServiceProvider serviceProvider, + ISettingsService settingsService + ) { + _appSettings = appSettings.Value; + _serviceProvider = serviceProvider; + _settingsService = settingsService; + } + + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // using var scope = _serviceProvider.CreateScope(); + // TODO: Add your user initialization logic here. + // Example: + // var userService = scope.ServiceProvider.GetRequiredService(); + // await userService.InitializeUsersAsync(); + + var dataDir = Path.Combine(Path.DirectorySeparatorChar.ToString(), "data"); + var settingsPath = Path.Combine(dataDir, "settings.json"); + + // Ensure the data directory exists + if (!Directory.Exists(dataDir)) { + Directory.CreateDirectory(dataDir); + } + + var loadSettingsResult = await _settingsService.LoadAsync(); + if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) { + throw new Exception("Failed to load settings."); + } + + var settings = loadSettingsResult.Value; + + if (!settings.Init) { + var initializeResult = settings.Initialize(_appSettings.Auth.Pepper); + if (!initializeResult.IsSuccess || initializeResult.Value == null) + throw new Exception(string.Join(", ", initializeResult.Messages)); + + settings = initializeResult.Value; + + var saveResult = await _settingsService.SaveAsync(settings); + if (!saveResult.IsSuccess) { + throw new Exception("Failed to save initialized settings."); + } + } + + + await Task.CompletedTask; + } + } +} diff --git a/src/LetsEncryptServer/Configuration.cs b/src/LetsEncryptServer/Configuration.cs index be26176..224048a 100644 --- a/src/LetsEncryptServer/Configuration.cs +++ b/src/LetsEncryptServer/Configuration.cs @@ -9,7 +9,26 @@ namespace MaksIT.LetsEncryptServer { public required string ServiceToReload { get; set; } } + public class Auth { + + public required string Secret { get; set; } + + public required string Issuer { get; set; } + public required string Audience { get; set; } + public required int Expiration { get; set; } + + public required int RefreshExpiration { get; set; } + + + public required string Pepper { get; set; } + + } + public class Configuration : ILetsEncryptConfiguration { + public required Auth Auth { get; set; } + + public required string SettingsFile { get; set; } + public required string Production { get; set; } public required string Staging { get; set; } diff --git a/src/LetsEncryptServer/Controllers/AccountController.cs b/src/LetsEncryptServer/Controllers/AccountController.cs index b2c18d5..b79fca5 100644 --- a/src/LetsEncryptServer/Controllers/AccountController.cs +++ b/src/LetsEncryptServer/Controllers/AccountController.cs @@ -8,7 +8,7 @@ namespace MaksIT.LetsEncryptServer.Controllers; [ApiController] [Route("api")] public class AccountController : ControllerBase { - private readonly IAccountRestService _accountService; + private readonly IAccountService _accountService; public AccountController( IAccountService accountService diff --git a/src/LetsEncryptServer/Controllers/IdentityController.cs b/src/LetsEncryptServer/Controllers/IdentityController.cs new file mode 100644 index 0000000..c00a65c --- /dev/null +++ b/src/LetsEncryptServer/Controllers/IdentityController.cs @@ -0,0 +1,49 @@ +using MaksIT.LetsEncryptServer.Services; +using Microsoft.AspNetCore.Mvc; +using Models.LetsEncryptServer.Identity.Login; +using Models.LetsEncryptServer.Identity.Logout; + + +namespace MaksIT.LetsEncryptServer.Controllers; + +[ApiController] +[Route("api/identity")] +public class IdentityController( + IIdentityService identityService +) : ControllerBase { + + private readonly IIdentityService _identityService = identityService; + + #region Login/Refresh/Logout + [HttpPost("login")] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + public async Task Login([FromBody] LoginRequest requestData) { + var result = await _identityService.LoginAsync(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(); + //} + #endregion + + + +} diff --git a/src/LetsEncryptServer/Domain/Settings.cs b/src/LetsEncryptServer/Domain/Settings.cs new file mode 100644 index 0000000..dfb7d71 --- /dev/null +++ b/src/LetsEncryptServer/Domain/Settings.cs @@ -0,0 +1,59 @@ +using MaksIT.Core.Abstractions.Domain; +using MaksIT.Core.Security; +using MaksIT.Results; + +namespace MaksIT.LetsEncryptServer.Domain; + +public class Settings : DomainObjectBase { + public bool Init { get; set; } + public List Users { get; set; } = []; + + public Settings() {} + + public Result Initialize(string pepper) { + var userResult = new User("admin") + .SetPassword("password", pepper); + + if (!userResult.IsSuccess || userResult.Value == null) { + return userResult.ToResultOfType(_ => null); + } + + Init = true; + Users = [userResult.Value]; + + return Result.Ok(this); + } + + 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 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 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."); + } +} \ No newline at end of file diff --git a/src/LetsEncryptServer/Domain/User.cs b/src/LetsEncryptServer/Domain/User.cs new file mode 100644 index 0000000..2e93e81 --- /dev/null +++ b/src/LetsEncryptServer/Domain/User.cs @@ -0,0 +1,64 @@ +using MaksIT.Core.Abstractions.Domain; +using MaksIT.Core.Security; +using MaksIT.Results; + +namespace MaksIT.LetsEncryptServer.Domain; + +public class User( + Guid id, + string name + ) : 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 User( + string name + ) : this( + Guid.NewGuid(), + name + ) { } + + + // 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); + + Salt = saltedHash.Value.Salt; + Hash = saltedHash.Value.Hash; + + 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 + public Result ValidatePassword(string password, string pepper) { + if (PasswordHasher.TryValidateHash(password, Salt, Hash, pepper, out var isValid, out var errorMessage)) { + if (isValid) + return Result.Ok(); + + return Result.Unauthorized("Invalid password."); + } + + return Result.InternalServerError(null, errorMessage); + } + + // For persistence + public User SeltSaltedHash(string salt, string hash) { + Salt = salt; + Hash = hash; + + return this; + } +} \ No newline at end of file diff --git a/src/LetsEncryptServer/Dto/SettingsDto.cs b/src/LetsEncryptServer/Dto/SettingsDto.cs new file mode 100644 index 0000000..95709aa --- /dev/null +++ b/src/LetsEncryptServer/Dto/SettingsDto.cs @@ -0,0 +1,6 @@ +namespace MaksIT.LetsEncryptServer.Dto; + +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 new file mode 100644 index 0000000..d7a0399 --- /dev/null +++ b/src/LetsEncryptServer/Dto/UserDto.cs @@ -0,0 +1,8 @@ +namespace MaksIT.LetsEncryptServer.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; } +} diff --git a/src/LetsEncryptServer/LetsEncryptServer.csproj b/src/LetsEncryptServer/LetsEncryptServer.csproj index 7b89f4d..653d94b 100644 --- a/src/LetsEncryptServer/LetsEncryptServer.csproj +++ b/src/LetsEncryptServer/LetsEncryptServer.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + MaksIT.$(MSBuildProjectName.Replace(" ", "_")) Linux ..\docker-compose.dcproj diff --git a/src/LetsEncryptServer/Program.cs b/src/LetsEncryptServer/Program.cs index 90f9569..241eb06 100644 --- a/src/LetsEncryptServer/Program.cs +++ b/src/LetsEncryptServer/Program.cs @@ -1,10 +1,10 @@ +using System.Text.Json.Serialization; using MaksIT.Core.Logging; using MaksIT.Core.Webapi.Middlewares; using MaksIT.LetsEncrypt.Extensions; using MaksIT.LetsEncryptServer; -using MaksIT.LetsEncryptServer.BackgroundServices; using MaksIT.LetsEncryptServer.Services; -using System.Text.Json.Serialization; +using MaksIT.LetsEncryptServer.BackgroundServices; var builder = WebApplication.CreateBuilder(args); @@ -48,11 +48,19 @@ builder.Services.AddMemoryCache(); builder.Services.RegisterLetsEncrypt(appSettings); +#region Work with files concurrently builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +#endregion + builder.Services.AddHttpClient(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Hosted services builder.Services.AddHostedService(); +builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/src/LetsEncryptServer/Services/AccoutService.cs b/src/LetsEncryptServer/Services/AccoutService.cs index ae8fa03..a91f4fe 100644 --- a/src/LetsEncryptServer/Services/AccoutService.cs +++ b/src/LetsEncryptServer/Services/AccoutService.cs @@ -2,22 +2,14 @@ using LetsEncryptServer.Abstractions; using MaksIT.Core.Webapi.Models; using MaksIT.LetsEncrypt.Entities; -using MaksIT.Models; using MaksIT.Models.LetsEncryptServer.Account.Requests; using MaksIT.Models.LetsEncryptServer.Account.Responses; using MaksIT.Results; -using System; -using static System.Collections.Specialized.BitVector32; namespace MaksIT.LetsEncryptServer.Services; -public interface IAccountInternalService { - -} - - -public interface IAccountRestService { +public interface IAccountService { Task> GetAccountsAsync(); Task> GetAccountAsync(Guid accountId); Task> PostAccountAsync(PostAccountRequest requestData); @@ -25,8 +17,6 @@ public interface IAccountRestService { Task DeleteAccountAsync(Guid accountId); } -public interface IAccountService : IAccountInternalService, IAccountRestService { } - public class AccountService : ServiceBase, IAccountService { private readonly ILogger _logger; diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 2a5329d..68c3643 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -195,9 +195,12 @@ public class CertsFlowService : ICertsFlowService { var sessionId = sessionResult.Value.Value; var initResult = await InitAsync(sessionId, accountId, description, contacts); - if (!initResult.IsSuccess) + if (!initResult.IsSuccess || initResult.Value == null) return initResult.ToResultOfType(_ => null); + if (accountId == null) + accountId = initResult.Value; + var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType); if (!challengesResult.IsSuccess) return challengesResult.ToResultOfType(_ => null); @@ -217,7 +220,7 @@ public class CertsFlowService : ICertsFlowService { return certsResult.ToResultOfType(default); if (!isStaging) { - var applyCertsResult = await ApplyCertificatesAsync(accountId ?? Guid.Empty); + var applyCertsResult = await ApplyCertificatesAsync(accountId.Value); if (!applyCertsResult.IsSuccess) return applyCertsResult.ToResultOfType(_ => null); } diff --git a/src/LetsEncryptServer/Services/IdentityService.cs b/src/LetsEncryptServer/Services/IdentityService.cs new file mode 100644 index 0000000..2acaeca --- /dev/null +++ b/src/LetsEncryptServer/Services/IdentityService.cs @@ -0,0 +1,108 @@ +using MaksIT.Core.Security.JWT; +using MaksIT.LetsEncryptServer.Domain; +using MaksIT.Results; +using Microsoft.Extensions.Options; +using Models.LetsEncryptServer.Identity.Login; +using Models.LetsEncryptServer.Identity.Logout; +using System.Security.Claims; + +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); + #endregion +} + +public class IdentityService( + IOptions appsettings, + ISettingsService settingsService +) : IIdentityService { + + + private readonly Configuration _appSettings = appsettings.Value; + private readonly ISettingsService _settingsService = settingsService; + + #region Login/Refresh/Logout + public async Task> LoginAsync(LoginRequest requestData) { + + var loadSettingsResult = await _settingsService.LoadAsync(); + if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) { + return loadSettingsResult.ToResultOfType(_ => null); + } + + var settings = loadSettingsResult.Value; + + var userResult = settings.GetUserByName(requestData.Username); + if (!userResult.IsSuccess || userResult.Value == null) + return userResult.ToResultOfType(_ => null); + + var user = userResult.Value; + + var validatePasswordResult = user.ValidatePassword(requestData.Password, _appSettings.Auth.Pepper); + if (!validatePasswordResult.IsSuccess) + return validatePasswordResult.ToResultOfType(default); + + 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(); + + var response = new LoginResponse { + TokenType = "Bearer", + Token = token, + ExpiresAt = claims.ExpiresAt.Value, + RefreshToken = refreshToken, + RefreshTokenExpiresAt = claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration) + }; + + return Result.Ok(response); + } + + + + + //public async Task> RefreshTokenAsync(RefreshTokenRequest requestData) { + // return await HandleTokenResponseAsync(() => + // _identityDomainService.RefreshTokenAsync(requestData.RefreshToken)); + //} + + //private static async Task> HandleTokenResponseAsync(Func>> tokenOperation) { + // var jwtTokenResult = await tokenOperation(); + // if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null) + // return jwtTokenResult.ToResultOfType(_ => null); + + // var jwtToken = jwtTokenResult.Value; + + // return Result.Ok(new LoginResponse { + // TokenType = jwtToken.TokenType, + // Token = jwtToken.Token, + // ExpiresAt = jwtToken.ExpiresAt, + // RefreshToken = jwtToken.RefreshToken, + // RefreshTokenExpiresAt = jwtToken.RefreshTokenExpiresAt + // }); + //} + + //public async Task Logout(JwtTokenData jwtTokenData, LogoutRequest requestData) { + // var logoutResult = await _identityDomainService.LogoutAsync(jwtTokenData.Username, jwtTokenData.Token, requestData.LogoutFromAllDevices); + // return logoutResult; + //} + #endregion + +} diff --git a/src/LetsEncryptServer/Services/SettingsService.cs b/src/LetsEncryptServer/Services/SettingsService.cs new file mode 100644 index 0000000..9d11887 --- /dev/null +++ b/src/LetsEncryptServer/Services/SettingsService.cs @@ -0,0 +1,88 @@ +using MaksIT.Core.Threading; +using MaksIT.LetsEncryptServer.Domain; +using MaksIT.LetsEncryptServer.Dto; +using MaksIT.Core.Extensions; +using MaksIT.Results; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.IO; +using System.Threading.Tasks; + +namespace MaksIT.LetsEncryptServer.Services; + +public interface ISettingsService { + Task> LoadAsync(); + Task SaveAsync(Settings settings); +} + +public class SettingsService : ISettingsService, IDisposable { + private readonly ILogger _logger; + private readonly string _settingsPath; + private readonly LockManager _lockManager; + + public SettingsService( + ILogger logger, + IOptions appSettings + ) { + _logger = logger; + _settingsPath = appSettings.Value.SettingsFile; + _lockManager = new LockManager(); + } + + #region Internal I/O + + private async Task> LoadInternalAsync() { + try { + if (!File.Exists(_settingsPath)) + 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."); + + var settings = new Settings { + Init = settingsDto.Init, + Users = [.. settingsDto.Users.Select(userDto => new User(userDto.Id.ToGuid(), userDto.Name).SeltSaltedHash(userDto.Salt, userDto.Hash))] + }; + return Result.Ok(settings); + } catch (Exception ex) { + _logger.LogError(ex, "Error loading settings file."); + return Result.InternalServerError(new Settings(), ex.Message); + } + } + + 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); + } + } + + #endregion + + public async Task> LoadAsync() { + return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync()); + } + + public async Task SaveAsync(Settings settings) { + return await _lockManager.ExecuteWithLockAsync(() => SaveInternalAsync(settings)); + } + + public void Dispose() { + _lockManager.Dispose(); + } +} diff --git a/src/LetsEncryptServer/appsettings.json b/src/LetsEncryptServer/appsettings.json index 7ffa251..8bc7988 100644 --- a/src/LetsEncryptServer/appsettings.json +++ b/src/LetsEncryptServer/appsettings.json @@ -8,6 +8,18 @@ "AllowedHosts": "*", "Configuration": { + "SettingsFile": "/data/settings.json", + "Auth": { + "Secret": "", + "Issuer": "", + "Audience": "", + "Expiration": 60, + "RefreshExpiration": 120, + + + "Pepper": "" + }, + "Production": "https://acme-v02.api.letsencrypt.org/directory", "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", diff --git a/src/MaksIT.WebUI/package-lock.json b/src/MaksIT.WebUI/package-lock.json index f553b5d..4597d2b 100644 --- a/src/MaksIT.WebUI/package-lock.json +++ b/src/MaksIT.WebUI/package-lock.json @@ -1948,6 +1948,7 @@ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2035,6 +2036,7 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -2280,6 +2282,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3164,6 +3167,7 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5256,6 +5260,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5265,6 +5270,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5327,6 +5333,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5405,7 +5412,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5946,7 +5954,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.2", @@ -6020,6 +6029,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6150,6 +6160,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6246,6 +6257,7 @@ "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -6336,6 +6348,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/MaksIT.WebUI/src/AppMap.tsx b/src/MaksIT.WebUI/src/AppMap.tsx index dad92a3..aee877e 100644 --- a/src/MaksIT.WebUI/src/AppMap.tsx +++ b/src/MaksIT.WebUI/src/AppMap.tsx @@ -10,6 +10,7 @@ import { Toast } from './components/Toast' import { UtilitiesPage } from './pages/UtilitiesPage' import { RegisterPage } from './pages/RegisterPage' import { LetsEncryptTermsOfServicePage } from './pages/LetsEncryptTermsOfServicePage' +import { UserPage } from './pages/UserPage' interface LayoutWrapperProps { @@ -45,7 +46,14 @@ const LayoutWrapper: FC = (props) => { } footer={ { - children:

© {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}

+ children:

+ © {new Date().getFullYear()} + {import.meta.env.VITE_COMPANY} + +

} } >{children} @@ -90,25 +98,27 @@ const AppMap: AppMapType[] = [ routes: ['/terms-of-service'], page: LetsEncryptTermsOfServicePage, linkArea: [LinkArea.SideMenu] - } + }, - // { - // title: 'Login', - // routes: ['/login'], - // page: LoginScreen, - // useAuth: false, - // useLayout: false - // }, + { + title: 'Login', + routes: ['/login'], + page: LoginScreen, + useAuth: false, + useLayout: false + }, + + { + title: 'User', + routes: ['/user/:userId'], + page: UserPage + }, // { // title: 'About', // routes: ['/about'], // page: Home // }, - // { - // title: 'User', - // routes: ['/user/:userId'], - // page: Users - // }, + // { // title: 'Organizations', // routes: ['/organizations', '/organization/:organizationId'], @@ -221,14 +231,12 @@ const GetRoutes = () => { - // {PageComponent} - // - // - // : PageComponent} - - element={PageComponent} + element={useAuth + ? + {PageComponent} + + + : PageComponent} /> ) }) diff --git a/src/MaksIT.WebUI/src/components/Authorization.tsx b/src/MaksIT.WebUI/src/components/Authorization.tsx index 696c641..58cab88 100644 --- a/src/MaksIT.WebUI/src/components/Authorization.tsx +++ b/src/MaksIT.WebUI/src/components/Authorization.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { FC, useEffect, useMemo } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' import { useAppDispatch, useAppSelector } from '../redux/hooks' import { setIdentityFromLocalStorage } from '../redux/slices/identitySlice' @@ -7,44 +7,36 @@ interface AuthorizationProps { children: React.ReactNode; } -const Authorization = (props: AuthorizationProps) => { +const Authorization: FC = (props) => { const { children } = props const navigate = useNavigate() + const location = useLocation() const dispatch = useAppDispatch() const { identity } = useAppSelector((state) => state.identity) - const [loading, setLoading] = useState(true) + const isTokenExpired = useMemo(() => { + if (!identity || !identity.refreshTokenExpiresAt) + return true + + return new Date(identity.refreshTokenExpiresAt) < new Date() + }, [identity]) useEffect(() => { + // Load identity from local storage on mount dispatch(setIdentityFromLocalStorage()) - setLoading(false) }, [dispatch]) useEffect(() => { - if (!loading) { - if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) { - navigate('/login', { replace: true }) - } + if (!identity || isTokenExpired) { + // Optionally, pass the current location for redirect after login + navigate('/login', { replace: true, state: { from: location } }) } - }, [identity, navigate, loading]) + }, [identity, isTokenExpired, navigate, location]) - // Render a simple loading spinner while loading (Tailwind v4 compatible) - if (loading) { - return ( -
-
-
- ) - } - - if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) { - return null - } - - return <>{children} + return identity && !isTokenExpired + ? children + : <> } -export { - Authorization -} \ No newline at end of file +export { Authorization } \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/components/LoginScreen.tsx b/src/MaksIT.WebUI/src/components/LoginScreen.tsx index 2095ccc..d0cad47 100644 --- a/src/MaksIT.WebUI/src/components/LoginScreen.tsx +++ b/src/MaksIT.WebUI/src/components/LoginScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { FC, useEffect, useState } from 'react' import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest' import { useAppDispatch, useAppSelector } from '../redux/hooks' import { login } from '../redux/slices/identitySlice' @@ -6,7 +6,7 @@ import { useFormState } from '../hooks/useFormState' import { useNavigate } from 'react-router-dom' import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors' -const LoginScreen: React.FC = () => { +const LoginScreen: FC = () => { const [use2FA, setUse2FA] = useState(false) const [use2FARecovery, setUse2FARecovery] = useState(false) @@ -48,14 +48,6 @@ const LoginScreen: React.FC = () => { dispatch(login(formState)) } - // Utility classes - const inputClasses = - 'block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' - const checkboxClasses = - 'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-200' - const buttonPrimaryClasses = - 'w-full py-2 px-4 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold' - return (
@@ -68,8 +60,8 @@ const LoginScreen: React.FC = () => {
handleInputChange('username', e.target.value)} errorText={errors.username} diff --git a/src/MaksIT.WebUI/src/components/UserButton.tsx b/src/MaksIT.WebUI/src/components/UserButton.tsx index 9a5a3d8..bca5d32 100644 --- a/src/MaksIT.WebUI/src/components/UserButton.tsx +++ b/src/MaksIT.WebUI/src/components/UserButton.tsx @@ -3,12 +3,7 @@ import { setShowUserOffcanvas } from '../redux/slices/identitySlice' const UserButton = () => { const dispatch = useAppDispatch() - //const { identity } = useAppSelector(state => state.identity) - - const identity = { - username: 'JohnDoe', - isGlobalAdmin: true - } + const { identity } = useAppSelector(state => state.identity) if (!identity) return <> @@ -16,9 +11,7 @@ const UserButton = () => { className={'bg-white text-blue-500 px-2 py-1 rounded'} onClick={() => { dispatch(setShowUserOffcanvas()) - }}> - {`${identity.username} ${identity.isGlobalAdmin ? '(Global Admin)' : ''}`.trim()} - + }}>{identity.username} } export { diff --git a/src/MaksIT.WebUI/src/forms/EditIdentity.tsx b/src/MaksIT.WebUI/src/forms/EditIdentity.tsx new file mode 100644 index 0000000..c98fce6 --- /dev/null +++ b/src/MaksIT.WebUI/src/forms/EditIdentity.tsx @@ -0,0 +1,8 @@ +const EditIdentity = () => { + return
Edit Identity Form
+} + +export { + EditIdentity +} + diff --git a/src/MaksIT.WebUI/src/forms/Home.tsx b/src/MaksIT.WebUI/src/forms/Home.tsx index 068c281..3540c80 100644 --- a/src/MaksIT.WebUI/src/forms/Home.tsx +++ b/src/MaksIT.WebUI/src/forms/Home.tsx @@ -5,10 +5,11 @@ import { CacheAccount } from '../entities/CacheAccount' import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse' import { deleteData, getData, postData } from '../axiosConfig' import { ApiRoutes, GetApiRoute } from '../AppMap' -import { formatISODateString } from '../functions' +import { enumToArr, formatISODateString } from '../functions' import { addToast } from '../components/Toast/addToast' import { Offcanvas } from '../components/Offcanvas' import { EditAccount } from './EditAccount' +import { ChallengeType } from '../entities/ChallengeType' const Home: FC = () => { @@ -113,14 +114,12 @@ const Home: FC = () => { ))} - ({ value: ct.value, label: ct.displayValue })) + .filter(ct => ct.value !== ChallengeType.dns01)} + value={acc.challengeType} disabled={true} />

Hostnames:

@@ -148,11 +147,13 @@ const Home: FC = () => { ))} - = () => { {formState.contacts.map((contact) => (
  • {contact} - - { - const updatedContacts = formState.contacts.filter(c => c !== contact) - handleInputChange('contacts', updatedContacts) - }} - > - - - + { + const updatedContacts = formState.contacts.filter(c => c !== contact) + handleInputChange('contacts', updatedContacts) + }} + > + +
  • ))} @@ -170,18 +169,17 @@ const Register: FC = () => {

    Hostnames:

      {formState.hostnames.map((hostname) => ( -
    • +
    • {hostname} - - { - const updatedHostnames = formState.hostnames.filter(h => h !== hostname) - handleInputChange('hostnames', updatedHostnames) - }} - > - - - + { + const updatedHostnames = formState.hostnames.filter(h => h !== hostname) + handleInputChange('hostnames', updatedHostnames) + }} + > + +
    • ))}
    diff --git a/src/MaksIT.WebUI/src/pages/UserPage.tsx b/src/MaksIT.WebUI/src/pages/UserPage.tsx new file mode 100644 index 0000000..8131373 --- /dev/null +++ b/src/MaksIT.WebUI/src/pages/UserPage.tsx @@ -0,0 +1,10 @@ +import { EditIdentity } from '../forms/EditIdentity' + + +const UserPage = () => { + return +} + +export { + UserPage +} \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/redux/store.ts b/src/MaksIT.WebUI/src/redux/store.ts index 7c97d8b..d0c4cd7 100644 --- a/src/MaksIT.WebUI/src/redux/store.ts +++ b/src/MaksIT.WebUI/src/redux/store.ts @@ -5,7 +5,7 @@ import identityReducer from './slices/identitySlice' export const store = configureStore({ reducer: { loader: loaderReducer, - //identity: identityReducer, + identity: identityReducer, }, }) diff --git a/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs b/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs new file mode 100644 index 0000000..684861a --- /dev/null +++ b/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs @@ -0,0 +1,11 @@ +using MaksIT.Core.Abstractions.Webapi; + + +namespace Models.LetsEncryptServer.Identity.Login; + +public class LoginRequest : RequestModelBase { + public required string Username { get; set; } + public required string Password { get; set; } + public string? TwoFactorCode { get; set; } + public string? TwoFactorRecoveryCode { get; set; } +} diff --git a/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs b/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs new file mode 100644 index 0000000..33ce2a7 --- /dev/null +++ b/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs @@ -0,0 +1,13 @@ +using MaksIT.Core.Abstractions.Webapi; + + +namespace Models.LetsEncryptServer.Identity.Login; + +public class LoginResponse : ResponseModelBase { + + public required string TokenType { get; set; } + public required string Token { get; set; } + public required DateTime ExpiresAt { get; set; } + public required string RefreshToken { get; set; } + public required DateTime RefreshTokenExpiresAt { get; set; } +} \ No newline at end of file diff --git a/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs b/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs new file mode 100644 index 0000000..93b15c1 --- /dev/null +++ b/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs @@ -0,0 +1,8 @@ +using MaksIT.Core.Abstractions.Webapi; + + +namespace Models.LetsEncryptServer.Identity.Login; + +public class RefreshTokenRequest : RequestModelBase { + public required string RefreshToken { get; set; } // The refresh token used for renewing access +} diff --git a/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs b/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs new file mode 100644 index 0000000..a7a7a67 --- /dev/null +++ b/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs @@ -0,0 +1,8 @@ +using MaksIT.Core.Abstractions.Webapi; + + +namespace Models.LetsEncryptServer.Identity.Logout; + +public class LogoutRequest : RequestModelBase { + public bool LogoutFromAllDevices { get; set; } +} diff --git a/src/Models/Models.csproj b/src/Models/Models.csproj index dd499c8..32b2a26 100644 --- a/src/Models/Models.csproj +++ b/src/Models/Models.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/docker-compose.override.yml b/src/docker-compose.override.yml index d14fa56..ef76fd0 100644 --- a/src/docker-compose.override.yml +++ b/src/docker-compose.override.yml @@ -27,6 +27,7 @@ services: volumes: - D:\Compose\MaksIT.CertsUI\acme:/acme - D:\Compose\MaksIT.CertsUI\cache:/cache + - D:\Compose\MaksIT.CertsUI\data:/data - D:\Compose\MaksIT.CertsUI\tmp:/tmp - D:\Compose\MaksIT.CertsUI\configMap\appsettings.json:/configMap/appsettings.json:ro - D:\Compose\MaksIT.CertsUI\secrets\appsecrets.json:/secrets/appsecrets.json:ro