diff --git a/src/Agent/Agent.csproj b/src/Agent/Agent.csproj index 8867ed6..834b7aa 100644 --- a/src/Agent/Agent.csproj +++ b/src/Agent/Agent.csproj @@ -9,11 +9,11 @@ - + - + diff --git a/src/LetsEncrypt.sln b/src/LetsEncrypt.sln index 5ec5fe7..45d0838 100644 --- a/src/LetsEncrypt.sln +++ b/src/LetsEncrypt.sln @@ -7,13 +7,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncrypt", "LetsEncrypt\ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3374FDB1-C95E-4103-8E14-5BBF0BDC4E9D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncryptServer", "LetsEncryptServer\LetsEncryptServer.csproj", "{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Webapi", "MaksIT.Webapi\MaksIT.Webapi.csproj", "{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}" EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{0233E43F-435D-4309-B20C-ECD4BFBD2E63}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Agent", "Agent\Agent.csproj", "{871BDED3-C6AE-437D-9B45-3AA3F184D002}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Models", "Models\Models.csproj", "{6814169B-D4D0-40B2-9FA9-89997DD44C30}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Models", "Models\MaksIT.Models.csproj", "{6814169B-D4D0-40B2-9FA9-89997DD44C30}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy", "ReverseProxy\ReverseProxy.csproj", "{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}" EndProject diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj index a87467b..dd0a0ec 100644 --- a/src/LetsEncrypt/LetsEncrypt.csproj +++ b/src/LetsEncrypt/LetsEncrypt.csproj @@ -8,13 +8,13 @@ - + - - - - - + + + + + diff --git a/src/LetsEncrypt/Services/JwsService.cs b/src/LetsEncrypt/Services/JwsService.cs index 1fea520..ff90df7 100644 --- a/src/LetsEncrypt/Services/JwsService.cs +++ b/src/LetsEncrypt/Services/JwsService.cs @@ -13,20 +13,13 @@ namespace MaksIT.LetsEncrypt.Services; public interface IJwsService { void SetKeyId(string location); - JwsMessage Encode(JwsHeader protectedHeader); - JwsMessage Encode(TPayload payload, JwsHeader protectedHeader); - string GetKeyAuthorization(string token); - - string Base64UrlEncoded(string s); - string Base64UrlEncoded(byte[] arg); } - public class JwsService : IJwsService { public Jwk _jwk; @@ -89,12 +82,17 @@ public class JwsService : IJwsService { $"{token}.{GetSha256Thumbprint()}"; private string GetSha256Thumbprint() { + + var thumbprint = new { + e = _jwk.Exponent, + kty = "RSA", + n = _jwk.Modulus + }; + var json = "{\"e\":\"" + _jwk.Exponent + "\",\"kty\":\"RSA\",\"n\":\"" + _jwk.Modulus + "\"}"; return Base64UrlEncoded(SHA256.HashData(Encoding.UTF8.GetBytes(json))); } - - public string Base64UrlEncoded(string s) => Base64UrlEncoded(Encoding.UTF8.GetBytes(s)); diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index a31e9fb..1e25acc 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -754,16 +754,12 @@ public class LetsEncryptService : ILetsEncryptService { private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName(); private Result HandleUnhandledException(Exception ex, string defaultMessage = "Let's Encrypt client unhandled exception") { - List messages = new() { defaultMessage }; - _logger.LogError(ex, messages.FirstOrDefault()); - ex.ExtractMessages().ForEach(m => messages.Add(m)); - return Result.InternalServerError([.. messages]); + _logger.LogError(ex, defaultMessage); + return Result.InternalServerError([defaultMessage, .. ex.ExtractMessages()]); } private Result HandleUnhandledException(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") { - List messages = new() { defaultMessage }; - _logger.LogError(ex, messages.FirstOrDefault()); - ex.ExtractMessages().ForEach(m => messages.Add(m)); - return Result.InternalServerError(defaultValue, [.. messages]); + _logger.LogError(ex, defaultMessage); + return Result.InternalServerError(defaultValue, [.. ex.ExtractMessages()]); } } diff --git a/src/LetsEncryptServer/Controllers/IdentityController.cs b/src/LetsEncryptServer/Controllers/IdentityController.cs deleted file mode 100644 index a56a9b3..0000000 --- a/src/LetsEncryptServer/Controllers/IdentityController.cs +++ /dev/null @@ -1,39 +0,0 @@ -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(); - } - - [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/Services/SettingsService.cs b/src/LetsEncryptServer/Services/SettingsService.cs deleted file mode 100644 index 1aef03e..0000000 --- a/src/LetsEncryptServer/Services/SettingsService.cs +++ /dev/null @@ -1,112 +0,0 @@ -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) - .SetName(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) { - var message = "Error loading settings file."; - _logger.LogError(ex, message); - return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); - } - } - - private async Task SaveInternalAsync(Settings settings) { - try { - 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() { - 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/MaksIT.WebUI/src/AppMap.tsx b/src/MaksIT.WebUI/src/AppMap.tsx index aee877e..1430d44 100644 --- a/src/MaksIT.WebUI/src/AppMap.tsx +++ b/src/MaksIT.WebUI/src/AppMap.tsx @@ -194,6 +194,8 @@ enum ApiRoutes { generateSecret = 'GET|/secret/generatesecret', // Identity + identityPatch = 'PATCH|/identity/user/{userId}', + identityLogin = 'POST|/identity/login', identityRefresh = 'POST|/identity/refresh', identityLogout = 'POST|/identity/logout', diff --git a/src/MaksIT.WebUI/src/axiosConfig.ts b/src/MaksIT.WebUI/src/axiosConfig.ts index 9339e34..477aaf3 100644 --- a/src/MaksIT.WebUI/src/axiosConfig.ts +++ b/src/MaksIT.WebUI/src/axiosConfig.ts @@ -13,7 +13,6 @@ const axiosInstance = axios.create({ timeout: 10000, // Set a timeout if needed }) - let isRefreshing = false let refreshPromise: Promise | null = null @@ -77,15 +76,14 @@ axiosInstance.interceptors.response.use( // Handle response error store.dispatch(hideLoader()) if (error.response) { - if (error.response.status === 401) { - // Handle unauthorized error (e.g., redirect to login) - } - else { - const contentType = error.response.headers['content-type'] + const contentType = error.response.headers['content-type'] - if (contentType && contentType.includes('application/problem+json')) { - const problem = error.response.data as ProblemDetails - addToast(`${problem.title}: ${problem.detail}`, 'error') + if (contentType && contentType.includes('application/problem+json')) { + const problem = error.response.data as ProblemDetails + addToast(`${problem.title}: ${problem.detail}`, 'error') + + if (error.response.status === 401) { + // Handle unauthorized error (e.g., redirect to login) } } } diff --git a/src/MaksIT.WebUI/src/components/LoginScreen.tsx b/src/MaksIT.WebUI/src/components/LoginScreen.tsx index d0cad47..8121577 100644 --- a/src/MaksIT.WebUI/src/components/LoginScreen.tsx +++ b/src/MaksIT.WebUI/src/components/LoginScreen.tsx @@ -1,14 +1,12 @@ -import React, { FC, useEffect, useState } from 'react' +import { FC, useEffect, KeyboardEvent } from 'react' import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest' import { useAppDispatch, useAppSelector } from '../redux/hooks' import { login } from '../redux/slices/identitySlice' import { useFormState } from '../hooks/useFormState' import { useNavigate } from 'react-router-dom' -import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors' +import { ButtonComponent, TextBoxComponent } from './editors' const LoginScreen: FC = () => { - const [use2FA, setUse2FA] = useState(false) - const [use2FARecovery, setUse2FARecovery] = useState(false) const navigate = useNavigate() const dispatch = useAppDispatch() @@ -32,13 +30,6 @@ const LoginScreen: FC = () => { } }, [identity, navigate]) - const handleUse2FA = (e: React.ChangeEvent) => { - setUse2FA(e.target.checked) - if (!e.target.checked) { - setUse2FARecovery(false) - } - } - const handleLogin = () => { if (!formIsValid) return @@ -48,14 +39,28 @@ const LoginScreen: FC = () => { dispatch(login(formState)) } - return ( -
-
- {/* Logo */} -
- {'Logo'} -
+ const handleSubmit = (e: KeyboardEvent) => { + if (e.key === 'Enter') handleLogin() + } + return ( +
+ {/* Top left logo and company name */} + + {'Logo'} + + +
+ {/* App logo and name above form */} +
+ {'App + {import.meta.env.VITE_APP_TITLE} +
{/* Form */}
@@ -66,7 +71,6 @@ const LoginScreen: FC = () => { onChange={(e) => handleInputChange('username', e.target.value)} errorText={errors.username} /> - { errorText={errors.password} />
- - {/* 2FA Options */} -
- - {use2FA && ( - setUse2FARecovery(e.target.checked)} - /> - )} -
- - {/* 2FA Inputs */} - {use2FA && ( -
- {use2FARecovery ? ( - handleInputChange('twoFactorRecoveryCode', e.target.value)} - errorText={errors.twoFactorRecoveryCode} - /> - ) : ( - handleInputChange('twoFactorCode', e.target.value)} - errorText={errors.twoFactorCode} - /> - )} -
- )} - {/* Submit */} { - return
Edit Identity Form
-} - -export { - EditIdentity -} - diff --git a/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx b/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx new file mode 100644 index 0000000..a621312 --- /dev/null +++ b/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx @@ -0,0 +1,107 @@ +import { FC } from 'react' +import { ButtonComponent, TextBoxComponent } from '../../../components/editors' +import { Offcanvas } from '../../../components/Offcanvas' +import { PatchUserChangePasswordRequest, PatchUserChangePasswordRequestSchema } from '../../../models/identity/user/PatchUserRequest' +import { useFormState } from '../../../hooks/useFormState' +import { PatchOperation } from '../../../models/PatchOperation' +import { UserResponse } from '../../../models/identity/user/UserResponse' +import { ApiRoutes, GetApiRoute } from '../../../AppMap' +import { patchData } from '../../../axiosConfig' +import { FormContainer, FormContent, FormHeader } from '../../../components/FormLayout' + +interface ChangePasswordProps { + userId: string; + isOpen?: boolean; + onClose?: () => void; +} + +const ChangePassword: FC = (props) => { + const { + userId, + isOpen = false, + onClose + } = props + + const { + formState, + errors, + formIsValid, + handleInputChange, + setInitialState + } = useFormState({ + initialState: { + password: '', + confirmPassword: '' + }, + validationSchema: PatchUserChangePasswordRequestSchema, + }) + + const handleOnClose = () => { + setInitialState({ + password: '', + confirmPassword: '' + }) + + onClose?.() + } + + const handleSave = () => { + if (formIsValid) { + const data: PatchUserChangePasswordRequest = { + password: formState.password, + operations: { + password: PatchOperation.SetField + } + } + + patchData(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data) + .then(response => { + if (!response) return + + handleOnClose() + }) + } + } + + return + + Change password + +
+ handleInputChange('password', e.target.value)} + /> + handleInputChange('confirmPassword', e.target.value)} + /> + + +
+
+
+
+} + +export { + ChangePassword +} \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx b/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx new file mode 100644 index 0000000..b65567f --- /dev/null +++ b/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx @@ -0,0 +1,53 @@ +import { FC, useState } from 'react' +import { FormContainer, FormContent, FormHeader } from '../../../components/FormLayout' +import { ButtonComponent } from '../../../components/editors' +import { UserResponse } from '../../../models/identity/user/UserResponse' +import { ChangePassword } from './ChangePassword' + + +interface EditUserProps { + userId: string; + onSubmitted?: (entity: UserResponse) => void + cancelEnabled?: boolean + onCancel?: () => void +} + +const EditUser : FC = (props) => { + const { + userId, + onSubmitted, + cancelEnabled = false, + onCancel + } = props + + const [showChangePassword, setShowChangePassword] = useState(false) + + return <> + + Edit user + +
+ setShowChangePassword(true)} + /> + +
+
+
+ + + setShowChangePassword(false)} + /> + +} + +export { + EditUser +} + diff --git a/src/MaksIT.WebUI/src/pages/UserPage.tsx b/src/MaksIT.WebUI/src/pages/UserPage.tsx index 8131373..f7fa70c 100644 --- a/src/MaksIT.WebUI/src/pages/UserPage.tsx +++ b/src/MaksIT.WebUI/src/pages/UserPage.tsx @@ -1,8 +1,11 @@ -import { EditIdentity } from '../forms/EditIdentity' +import { useParams } from 'react-router-dom' +import { EditUser } from '../forms/Users/EditUser' const UserPage = () => { - return + const { userId } = useParams<{ userId: string }>() + + return userId ? : <>User not found } export { diff --git a/src/MaksIT.Webapi/Abstractions/Authorization/Filters/BaseAsyncAuthorizationFilter.cs b/src/MaksIT.Webapi/Abstractions/Authorization/Filters/BaseAsyncAuthorizationFilter.cs new file mode 100644 index 0000000..7432965 --- /dev/null +++ b/src/MaksIT.Webapi/Abstractions/Authorization/Filters/BaseAsyncAuthorizationFilter.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using System.Threading.Tasks; + +namespace MaksIT.Webapi.Abstractions.Authorization.Filters; + +public abstract class BaseAsyncAuthorizationFilter( + ILogger logger +) : IAsyncAuthorizationFilter { + + protected readonly ILogger _logger = logger; + + // Derived classes must implement this method. + public abstract Task OnAuthorizationAsync(AuthorizationFilterContext context); +} \ No newline at end of file diff --git a/src/LetsEncryptServer/Abstractions/ServiceBase.cs b/src/MaksIT.Webapi/Abstractions/Services/ServiceBase.cs similarity index 61% rename from src/LetsEncryptServer/Abstractions/ServiceBase.cs rename to src/MaksIT.Webapi/Abstractions/Services/ServiceBase.cs index ef0cf92..bd87aa3 100644 --- a/src/LetsEncryptServer/Abstractions/ServiceBase.cs +++ b/src/MaksIT.Webapi/Abstractions/Services/ServiceBase.cs @@ -1,9 +1,14 @@ -using MaksIT.Results; +using MaksIT.Webapi; +using MaksIT.Results; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; -namespace LetsEncryptServer.Abstractions; +namespace MaksIT.Webapi.Abstractions.Services; -public abstract class ServiceBase { +public abstract class ServiceBase(ILogger logger, IOptions appSettings) { + protected readonly ILogger _logger = logger; + protected readonly Configuration _appSettings = appSettings.Value; protected Result UnsupportedPatchOperationResponse() { return Result.BadRequest("Unsupported operation"); diff --git a/src/MaksIT.Webapi/Authorization/Extensions/HttpContextExtension.cs b/src/MaksIT.Webapi/Authorization/Extensions/HttpContextExtension.cs new file mode 100644 index 0000000..75606dc --- /dev/null +++ b/src/MaksIT.Webapi/Authorization/Extensions/HttpContextExtension.cs @@ -0,0 +1,14 @@ +using MaksIT.Results; + +namespace MaksIT.Webapi.Authorization.Extensions; + +public static class HttpContextExtension { + public static Result GetJwtTokenData(this HttpContext context) { + var jwtTokenData = context.Items[HttpContextValue.JwtTokenData] as JwtTokenData; + + if (jwtTokenData == null) + return Result.Forbidden(null, "JWT token data not found in the request"); + + return Result.Ok(jwtTokenData); + } +} diff --git a/src/MaksIT.Webapi/Authorization/Filters/JwtAuthorizationFilter.cs b/src/MaksIT.Webapi/Authorization/Filters/JwtAuthorizationFilter.cs new file mode 100644 index 0000000..b112d5e --- /dev/null +++ b/src/MaksIT.Webapi/Authorization/Filters/JwtAuthorizationFilter.cs @@ -0,0 +1,90 @@ +using MaksIT.Core.Security.JWT; +using MaksIT.Results; +using MaksIT.Webapi.Abstractions.Authorization.Filters; +using MaksIT.Webapi.Dto; +using MaksIT.Webapi.Services; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using System.Threading.Tasks; + +namespace MaksIT.Webapi.Authorization.Filters; + +public class JwtAuthorizationFilter : BaseAsyncAuthorizationFilter { + private const string BearerTokenHeaderName = "Authorization"; + private readonly Auth _jwtSettingsConfiguration; + private readonly ISettingsService _settingsService; + + public JwtAuthorizationFilter( + ILogger logger, + IOptions appSettings, + ISettingsService settingsService + ) : base(logger) { + _jwtSettingsConfiguration = appSettings.Value.Auth; + _settingsService = settingsService; + } + + public override async Task OnAuthorizationAsync(AuthorizationFilterContext context) { + var request = context.HttpContext.Request; + if (!request.Headers.TryGetValue(BearerTokenHeaderName, out var authorization)) { + context.Result = Result.Forbidden("Authorization header missing").ToActionResult(); + return; + } + + var token = authorization.FirstOrDefault()?.Split(' ').Last(); + var validationResult = await ValidateJwtTokenAsync(token); + if (!validationResult.IsSuccess) { + context.Result = validationResult.ToActionResult(); + return; + } + + var tokenData = validationResult.Value; + + context.HttpContext.Items[HttpContextValue.JwtTokenData] = tokenData; + } + + private async Task> ValidateJwtTokenAsync(string? token) { + if (string.IsNullOrWhiteSpace(token)) + return Result.Forbidden(null, "Token is missing"); + + if (!JwtGenerator.TryValidateToken( + _jwtSettingsConfiguration.Secret, + _jwtSettingsConfiguration.Issuer, + _jwtSettingsConfiguration.Audience, + token, + out var jwtTokenClaims, + out string? errorMessage + )) { + return Result.InternalServerError(null, errorMessage); + } + + if (jwtTokenClaims == null || + jwtTokenClaims.Username == null || + jwtTokenClaims.Roles == null || + jwtTokenClaims.IssuedAt == null || + jwtTokenClaims.ExpiresAt == null + ) { + return Result.Forbidden(null, "Invalid JWT token or claims"); + } + + var settingsResult = await _settingsService.LoadAsync(); + if (!settingsResult.IsSuccess || settingsResult.Value == null) { + return Result.InternalServerError(null, "Failed to load settings"); + } + + var settings = settingsResult.Value; + var userResult = settings.GetUserByName(jwtTokenClaims.Username); + if (!userResult.IsSuccess || userResult.Value == null) { + return Result.Forbidden(null, "User not found"); + } + + var user = userResult.Value; + var jwtTokenData = new JwtTokenData { + Token = token, + Username = jwtTokenClaims.Username, + IssuedAt = jwtTokenClaims.IssuedAt.Value, + ExpiresAt = jwtTokenClaims.ExpiresAt.Value, + UserId = user.Id, + }; + return Result.Ok(jwtTokenData); + } +} \ No newline at end of file diff --git a/src/MaksIT.Webapi/Authorization/HttpContextValue.cs b/src/MaksIT.Webapi/Authorization/HttpContextValue.cs new file mode 100644 index 0000000..716ea9c --- /dev/null +++ b/src/MaksIT.Webapi/Authorization/HttpContextValue.cs @@ -0,0 +1,9 @@ +using MaksIT.Core.Abstractions; + +namespace MaksIT.Webapi.Authorization; + +public class HttpContextValue : Enumeration { + public static readonly HttpContextValue JwtTokenData = new(0, "JwtTokenData"); + + private HttpContextValue(int id, string value) : base(id, value) { } +} diff --git a/src/MaksIT.Webapi/Authorization/JwtTokenData.cs b/src/MaksIT.Webapi/Authorization/JwtTokenData.cs new file mode 100644 index 0000000..65170ce --- /dev/null +++ b/src/MaksIT.Webapi/Authorization/JwtTokenData.cs @@ -0,0 +1,9 @@ +namespace MaksIT.Webapi.Authorization; + +public class JwtTokenData { + public Guid UserId { get; set; } + public required string Username { get; set; } + public required string Token { get; set; } + public required DateTime IssuedAt { get; set; } + public required DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs b/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs similarity index 96% rename from src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs rename to src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs index 2c506f3..de2dcbb 100644 --- a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs +++ b/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs @@ -1,11 +1,11 @@ using Microsoft.Extensions.Options; -using MaksIT.LetsEncryptServer.Services; +using MaksIT.Webapi.Services; using MaksIT.LetsEncrypt.Entities; using MaksIT.Results; -namespace MaksIT.LetsEncryptServer.BackgroundServices { +namespace MaksIT.Webapi.BackgroundServices { public class AutoRenewal : BackgroundService { private readonly IOptions _appSettings; diff --git a/src/LetsEncryptServer/BackgroundServices/Initialization.cs b/src/MaksIT.Webapi/BackgroundServices/Initialization.cs similarity index 93% rename from src/LetsEncryptServer/BackgroundServices/Initialization.cs rename to src/MaksIT.Webapi/BackgroundServices/Initialization.cs index fb9e696..07448a8 100644 --- a/src/LetsEncryptServer/BackgroundServices/Initialization.cs +++ b/src/MaksIT.Webapi/BackgroundServices/Initialization.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Options; -using MaksIT.LetsEncryptServer.Domain; -using MaksIT.LetsEncryptServer.Services; +using MaksIT.Webapi.Domain; +using MaksIT.Webapi.Services; -namespace MaksIT.LetsEncryptServer.BackgroundServices { +namespace MaksIT.Webapi.BackgroundServices { public class Initialization : BackgroundService { private readonly IServiceProvider _serviceProvider; diff --git a/src/LetsEncryptServer/Configuration.cs b/src/MaksIT.Webapi/Configuration.cs similarity index 96% rename from src/LetsEncryptServer/Configuration.cs rename to src/MaksIT.Webapi/Configuration.cs index c6e3832..e34fe31 100644 --- a/src/LetsEncryptServer/Configuration.cs +++ b/src/MaksIT.Webapi/Configuration.cs @@ -1,6 +1,6 @@ using MaksIT.LetsEncrypt; -namespace MaksIT.LetsEncryptServer { +namespace MaksIT.Webapi { public class Agent { public required string AgentHostname { get; set; } diff --git a/src/LetsEncryptServer/Controllers/AccountController.cs b/src/MaksIT.Webapi/Controllers/AccountController.cs similarity index 82% rename from src/LetsEncryptServer/Controllers/AccountController.cs rename to src/MaksIT.Webapi/Controllers/AccountController.cs index b79fca5..563bb5b 100644 --- a/src/LetsEncryptServer/Controllers/AccountController.cs +++ b/src/MaksIT.Webapi/Controllers/AccountController.cs @@ -1,12 +1,13 @@ -using Microsoft.AspNetCore.Mvc; +using MaksIT.Models.LetsEncryptServer.Account.Requests; +using MaksIT.Webapi.Authorization.Filters; +using MaksIT.Webapi.Services; +using Microsoft.AspNetCore.Mvc; -using MaksIT.LetsEncryptServer.Services; -using MaksIT.Models.LetsEncryptServer.Account.Requests; - -namespace MaksIT.LetsEncryptServer.Controllers; +namespace MaksIT.Webapi.Controllers; [ApiController] [Route("api")] +[ServiceFilter(typeof(JwtAuthorizationFilter))] public class AccountController : ControllerBase { private readonly IAccountService _accountService; @@ -18,6 +19,7 @@ public class AccountController : ControllerBase { #region Accounts + [ServiceFilter(typeof(JwtAuthorizationFilter))] [HttpGet("accounts")] public async Task GetAccounts() { var result = await _accountService.GetAccountsAsync(); diff --git a/src/LetsEncryptServer/Controllers/AgentController.cs b/src/MaksIT.Webapi/Controllers/AgentController.cs similarity index 79% rename from src/LetsEncryptServer/Controllers/AgentController.cs rename to src/MaksIT.Webapi/Controllers/AgentController.cs index 5ed23e1..73d276e 100644 --- a/src/LetsEncryptServer/Controllers/AgentController.cs +++ b/src/MaksIT.Webapi/Controllers/AgentController.cs @@ -1,4 +1,5 @@ -using MaksIT.LetsEncryptServer.Services; +using MaksIT.Webapi.Authorization.Filters; +using MaksIT.Webapi.Services; using Microsoft.AspNetCore.Mvc; namespace LetsEncryptServer.Controllers; @@ -6,6 +7,7 @@ namespace LetsEncryptServer.Controllers; [ApiController] [Route("api")] +[ServiceFilter(typeof(JwtAuthorizationFilter))] public class AgentController : ControllerBase { private readonly IAgentService _agentController; diff --git a/src/LetsEncryptServer/Controllers/CacheController.cs b/src/MaksIT.Webapi/Controllers/CacheController.cs similarity index 88% rename from src/LetsEncryptServer/Controllers/CacheController.cs rename to src/MaksIT.Webapi/Controllers/CacheController.cs index 4955853..6494e6d 100644 --- a/src/LetsEncryptServer/Controllers/CacheController.cs +++ b/src/MaksIT.Webapi/Controllers/CacheController.cs @@ -1,10 +1,12 @@ -using MaksIT.LetsEncryptServer.Services; +using MaksIT.Webapi.Authorization.Filters; +using MaksIT.Webapi.Services; using Microsoft.AspNetCore.Mvc; namespace LetsEncryptServer.Controllers; [ApiController] [Route("api")] +[ServiceFilter(typeof(JwtAuthorizationFilter))] public class CacheController(ICacheService cacheService) : ControllerBase { private readonly ICacheService _cacheService = cacheService; @@ -32,8 +34,8 @@ public class CacheController(ICacheService cacheService) : ControllerBase { } [HttpDelete("cache")] - public IActionResult DeleteCache() { - var result = _cacheService.DeleteCacheAsync(); + public async Task DeleteCache() { + var result = await _cacheService.DeleteCacheAsync(); return result.ToActionResult(); } diff --git a/src/LetsEncryptServer/Controllers/CertsFlowController.cs b/src/MaksIT.Webapi/Controllers/CertsFlowController.cs similarity index 94% rename from src/LetsEncryptServer/Controllers/CertsFlowController.cs rename to src/MaksIT.Webapi/Controllers/CertsFlowController.cs index 17a54e2..cc8fafc 100644 --- a/src/LetsEncryptServer/Controllers/CertsFlowController.cs +++ b/src/MaksIT.Webapi/Controllers/CertsFlowController.cs @@ -1,13 +1,15 @@ -using Microsoft.AspNetCore.Mvc; -using MaksIT.LetsEncryptServer.Services; using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; +using MaksIT.Webapi.Authorization.Filters; +using MaksIT.Webapi.Services; +using Microsoft.AspNetCore.Mvc; -namespace MaksIT.LetsEncryptServer.Controllers { +namespace MaksIT.Webapi.Controllers { /// /// Certificates flow controller, used for granular testing purposes /// [ApiController] [Route("api/certs")] + [ServiceFilter(typeof(JwtAuthorizationFilter))] public class CertsFlowController : ControllerBase { private readonly ICertsFlowService _certsFlowService; diff --git a/src/MaksIT.Webapi/Controllers/IdentityController.cs b/src/MaksIT.Webapi/Controllers/IdentityController.cs new file mode 100644 index 0000000..2f0fe81 --- /dev/null +++ b/src/MaksIT.Webapi/Controllers/IdentityController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; +using MaksIT.Webapi.Authorization.Extensions; +using MaksIT.Webapi.Authorization.Filters; +using MaksIT.Webapi.Services; + +using MaksIT.Models.LetsEncryptServer.Identity.Login; +using MaksIT.Models.LetsEncryptServer.Identity.Logout; +using MaksIT.Models.LetsEncryptServer.Identity.User; + + +namespace MaksIT.Webapi.Controllers; + +[ApiController] +[Route("api/identity")] +public class IdentityController( + IIdentityService identityService +) : ControllerBase { + + private readonly IIdentityService _identityService = identityService; + + + /// + /// Patch user data. + /// + /// Nullable Id as user can patch his own data + /// + /// + [ServiceFilter(typeof(JwtAuthorizationFilter))] + [HttpPatch("user/{id:guid}")] + [ProducesResponseType(typeof(UserResponse), StatusCodes.Status200OK)] + public async Task PatchUser(Guid id, [FromBody] PatchUserRequest requestData) { + var jwtTokenDataResult = HttpContext.GetJwtTokenData(); + if (!jwtTokenDataResult.IsSuccess || jwtTokenDataResult.Value == null) + return jwtTokenDataResult.ToActionResult(); + + var jwtTokenData = jwtTokenDataResult.Value; + + var result = await _identityService.PatchUserAsync(jwtTokenData, id, requestData); + return result.ToActionResult(); + } + + + #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(); + } + + [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/Controllers/WellKnownController.cs b/src/MaksIT.Webapi/Controllers/WellKnownController.cs similarity index 88% rename from src/LetsEncryptServer/Controllers/WellKnownController.cs rename to src/MaksIT.Webapi/Controllers/WellKnownController.cs index 7b995e8..df4b1a3 100644 --- a/src/LetsEncryptServer/Controllers/WellKnownController.cs +++ b/src/MaksIT.Webapi/Controllers/WellKnownController.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using MaksIT.LetsEncryptServer.Services; +using MaksIT.Webapi.Services; -namespace MaksIT.LetsEncryptServer.Controllers; +namespace MaksIT.Webapi.Controllers; [ApiController] [Route(".well-known")] diff --git a/src/LetsEncryptServer/Dockerfile b/src/MaksIT.Webapi/Dockerfile similarity index 58% rename from src/LetsEncryptServer/Dockerfile rename to src/MaksIT.Webapi/Dockerfile index aae13d1..8537442 100644 --- a/src/LetsEncryptServer/Dockerfile +++ b/src/MaksIT.Webapi/Dockerfile @@ -10,17 +10,17 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Models/Models.csproj", "Models/"] COPY ["LetsEncrypt/LetsEncrypt.csproj", "LetsEncrypt/"] -COPY ["LetsEncryptServer/LetsEncryptServer.csproj", "LetsEncryptServer/"] -RUN dotnet restore "./LetsEncryptServer/LetsEncryptServer.csproj" +COPY ["MaksIT.Webapi/MaksIT.Webapi.csproj", "MaksIT.Webapi/"] +RUN dotnet restore "./MaksIT.Webapi/MaksIT.Webapi.csproj" COPY . . -WORKDIR "/src/LetsEncryptServer" -RUN dotnet build "./LetsEncryptServer.csproj" -c $BUILD_CONFIGURATION -o /app/build +WORKDIR "/src/MaksIT.Webapi" +RUN dotnet build "./MaksIT.Webapi.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./LetsEncryptServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish "./MaksIT.Webapi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "LetsEncryptServer.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "MaksIT.Webapi.dll"] \ No newline at end of file diff --git a/src/LetsEncryptServer/Domain/JwtToken.cs b/src/MaksIT.Webapi/Domain/JwtToken.cs similarity index 97% rename from src/LetsEncryptServer/Domain/JwtToken.cs rename to src/MaksIT.Webapi/Domain/JwtToken.cs index 7d6ea76..489146c 100644 --- a/src/LetsEncryptServer/Domain/JwtToken.cs +++ b/src/MaksIT.Webapi/Domain/JwtToken.cs @@ -1,7 +1,7 @@ using MaksIT.Core.Abstractions.Domain; using System.Linq.Dynamic.Core.Tokenizer; -namespace MaksIT.LetsEncryptServer.Domain; +namespace MaksIT.Webapi.Domain; public class JwtToken(Guid id) : DomainDocumentBase(id) { diff --git a/src/LetsEncryptServer/Domain/Settings.cs b/src/MaksIT.Webapi/Domain/Settings.cs similarity index 91% rename from src/LetsEncryptServer/Domain/Settings.cs rename to src/MaksIT.Webapi/Domain/Settings.cs index 7318a7a..2da7fa0 100644 --- a/src/LetsEncryptServer/Domain/Settings.cs +++ b/src/MaksIT.Webapi/Domain/Settings.cs @@ -2,7 +2,7 @@ using MaksIT.Core.Security; using MaksIT.Results; -namespace MaksIT.LetsEncryptServer.Domain; +namespace MaksIT.Webapi.Domain; public class Settings : DomainObjectBase { public bool Init { get; set; } @@ -25,6 +25,13 @@ public class Settings : DomainObjectBase { return Result.Ok(this); } + public Result GetUserById(Guid id) { + var user = Users.FirstOrDefault(x => x.Id == id); + if (user == null) + return Result.NotFound(null, "User not found."); + return Result.Ok(user); + } + public Result GetUserByName(string name) { var user = Users.FirstOrDefault(x => x.Name == name); if (user == null) diff --git a/src/LetsEncryptServer/Domain/User.cs b/src/MaksIT.Webapi/Domain/User.cs similarity index 99% rename from src/LetsEncryptServer/Domain/User.cs rename to src/MaksIT.Webapi/Domain/User.cs index 432f8f2..50a256e 100644 --- a/src/LetsEncryptServer/Domain/User.cs +++ b/src/MaksIT.Webapi/Domain/User.cs @@ -2,7 +2,7 @@ using MaksIT.Core.Security; using MaksIT.Results; -namespace MaksIT.LetsEncryptServer.Domain; +namespace MaksIT.Webapi.Domain; public class User( Guid id diff --git a/src/LetsEncryptServer/Dto/JwtTokenDto.cs b/src/MaksIT.Webapi/Dto/JwtTokenDto.cs similarity index 90% rename from src/LetsEncryptServer/Dto/JwtTokenDto.cs rename to src/MaksIT.Webapi/Dto/JwtTokenDto.cs index d598d50..e07b776 100644 --- a/src/LetsEncryptServer/Dto/JwtTokenDto.cs +++ b/src/MaksIT.Webapi/Dto/JwtTokenDto.cs @@ -1,6 +1,6 @@ using MaksIT.Core.Abstractions.Dto; -namespace MaksIT.LetsEncryptServer.Dto; +namespace MaksIT.Webapi.Dto; public class JwtTokenDto : DtoDocumentBase { public required string Token { get; set; } diff --git a/src/LetsEncryptServer/Dto/SettingsDto.cs b/src/MaksIT.Webapi/Dto/SettingsDto.cs similarity index 75% rename from src/LetsEncryptServer/Dto/SettingsDto.cs rename to src/MaksIT.Webapi/Dto/SettingsDto.cs index 00b9111..88828e6 100644 --- a/src/LetsEncryptServer/Dto/SettingsDto.cs +++ b/src/MaksIT.Webapi/Dto/SettingsDto.cs @@ -1,4 +1,4 @@ -namespace MaksIT.LetsEncryptServer.Dto; +namespace MaksIT.Webapi.Dto; public class SettingsDto { public required bool Init { get; set; } diff --git a/src/LetsEncryptServer/Dto/UserDto.cs b/src/MaksIT.Webapi/Dto/UserDto.cs similarity index 90% rename from src/LetsEncryptServer/Dto/UserDto.cs rename to src/MaksIT.Webapi/Dto/UserDto.cs index f4132e7..6bc6667 100644 --- a/src/LetsEncryptServer/Dto/UserDto.cs +++ b/src/MaksIT.Webapi/Dto/UserDto.cs @@ -1,6 +1,6 @@ using MaksIT.Core.Abstractions.Dto; -namespace MaksIT.LetsEncryptServer.Dto; +namespace MaksIT.Webapi.Dto; public class UserDto : DtoDocumentBase { public required string Name { get; set; } = string.Empty; diff --git a/src/LetsEncryptServer/LetsEncryptServer.http b/src/MaksIT.Webapi/LetsEncryptServer.http similarity index 100% rename from src/LetsEncryptServer/LetsEncryptServer.http rename to src/MaksIT.Webapi/LetsEncryptServer.http diff --git a/src/LetsEncryptServer/LetsEncryptServer.csproj b/src/MaksIT.Webapi/MaksIT.Webapi.csproj similarity index 77% rename from src/LetsEncryptServer/LetsEncryptServer.csproj rename to src/MaksIT.Webapi/MaksIT.Webapi.csproj index 653d94b..25876c2 100644 --- a/src/LetsEncryptServer/LetsEncryptServer.csproj +++ b/src/MaksIT.Webapi/MaksIT.Webapi.csproj @@ -1,24 +1,24 @@ - + net8.0 enable enable - MaksIT.$(MSBuildProjectName.Replace(" ", "_")) + $(MSBuildProjectName.Replace(" ", "_")) Linux ..\docker-compose.dcproj - + - + - + diff --git a/src/LetsEncryptServer/Program.cs b/src/MaksIT.Webapi/Program.cs similarity index 88% rename from src/LetsEncryptServer/Program.cs rename to src/MaksIT.Webapi/Program.cs index 241eb06..6bead25 100644 --- a/src/LetsEncryptServer/Program.cs +++ b/src/MaksIT.Webapi/Program.cs @@ -1,19 +1,17 @@ -using System.Text.Json.Serialization; using MaksIT.Core.Logging; using MaksIT.Core.Webapi.Middlewares; using MaksIT.LetsEncrypt.Extensions; -using MaksIT.LetsEncryptServer; -using MaksIT.LetsEncryptServer.Services; -using MaksIT.LetsEncryptServer.BackgroundServices; +using MaksIT.Webapi; +using MaksIT.Webapi.Authorization.Filters; +using MaksIT.Webapi.BackgroundServices; +using MaksIT.Webapi.Services; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); -// Extract configuration +#region Configuration setup var configuration = builder.Configuration; -// Add logging -builder.Logging.AddConsoleLogger(); - var configMapPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "configMap", "appsettings.json"); if (File.Exists(configMapPath)) { configuration.AddJsonFile(configMapPath, optional: false, reloadOnChange: true); @@ -30,7 +28,10 @@ var appSettings = configurationSection.Get() ?? throw new Argumen // Allow configurations to be available through IOptions builder.Services.Configure(configurationSection); +#endregion +// Add logging +builder.Logging.AddConsoleLogger(); // Add services to the container. builder.Services.AddControllers() @@ -38,9 +39,14 @@ builder.Services.AddControllers() options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); +// Add custom authorization filter +builder.Services.AddScoped(); + +#region Swagger // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +#endregion builder.Services.AddCors(); @@ -58,9 +64,10 @@ builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); -// Hosted services +#region Hosted services builder.Services.AddHostedService(); builder.Services.AddHostedService(); +#endregion var app = builder.Build(); diff --git a/src/LetsEncryptServer/Properties/launchSettings.json b/src/MaksIT.Webapi/Properties/launchSettings.json similarity index 100% rename from src/LetsEncryptServer/Properties/launchSettings.json rename to src/MaksIT.Webapi/Properties/launchSettings.json diff --git a/src/LetsEncryptServer/Services/AccoutService.cs b/src/MaksIT.Webapi/Services/AccoutService.cs similarity index 83% rename from src/LetsEncryptServer/Services/AccoutService.cs rename to src/MaksIT.Webapi/Services/AccoutService.cs index a91f4fe..e3d0584 100644 --- a/src/LetsEncryptServer/Services/AccoutService.cs +++ b/src/MaksIT.Webapi/Services/AccoutService.cs @@ -1,13 +1,13 @@ - -using LetsEncryptServer.Abstractions; -using MaksIT.Core.Webapi.Models; +using MaksIT.Core.Webapi.Models; using MaksIT.LetsEncrypt.Entities; using MaksIT.Models.LetsEncryptServer.Account.Requests; using MaksIT.Models.LetsEncryptServer.Account.Responses; using MaksIT.Results; +using MaksIT.Webapi.Abstractions.Services; +using Microsoft.Extensions.Options; -namespace MaksIT.LetsEncryptServer.Services; +namespace MaksIT.Webapi.Services; public interface IAccountService { Task> GetAccountsAsync(); @@ -17,27 +17,21 @@ public interface IAccountService { Task DeleteAccountAsync(Guid accountId); } -public class AccountService : ServiceBase, IAccountService { - - private readonly ILogger _logger; - private readonly ICacheService _cacheService; - private readonly ICertsFlowService _certsFlowService; - - public AccountService( - ILogger logger, - ICacheService cacheService, - ICertsFlowService certsFlowService - ) { - _logger = logger; - _cacheService = cacheService; - _certsFlowService = certsFlowService; - } +public class AccountService( + ILogger logger, + IOptions appSettings, + ICacheService cacheService, + ICertsFlowService certsFlowService +) : ServiceBase( + logger, + appSettings +), IAccountService { #region Accounts public async Task> GetAccountsAsync() { - var accountsFromCacheResult = await _cacheService.LoadAccountsFromCacheAsync(); + var accountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync(); if (!accountsFromCacheResult.IsSuccess || accountsFromCacheResult.Value == null) { return accountsFromCacheResult .ToResultOfType(_ => null); @@ -51,7 +45,7 @@ public class AccountService : ServiceBase, IAccountService { } public async Task> GetAccountAsync(Guid accountId) { - var loadFromCacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId); + var loadFromCacheResult = await cacheService.LoadAccountFromCacheAsync(accountId); if (!loadFromCacheResult.IsSuccess || loadFromCacheResult.Value == null) { return loadFromCacheResult.ToResultOfType(_ => null); } @@ -63,7 +57,7 @@ public class AccountService : ServiceBase, IAccountService { public async Task> PostAccountAsync(PostAccountRequest requestData) { - var fullFlowResult = await _certsFlowService.FullFlow( + var fullFlowResult = await certsFlowService.FullFlow( requestData.IsStaging, null, requestData.Description, @@ -77,7 +71,7 @@ public class AccountService : ServiceBase, IAccountService { var accountId = fullFlowResult.Value.Value; - var loadAccountFromCacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId); + var loadAccountFromCacheResult = await cacheService.LoadAccountFromCacheAsync(accountId); if (!loadAccountFromCacheResult.IsSuccess || loadAccountFromCacheResult.Value == null) { return loadAccountFromCacheResult.ToResultOfType(_ => null); } @@ -88,7 +82,7 @@ public class AccountService : ServiceBase, IAccountService { } public async Task> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) { - var loadAccountResult = await _cacheService.LoadAccountFromCacheAsync(accountId); + var loadAccountResult = await cacheService.LoadAccountFromCacheAsync(accountId); if (!loadAccountResult.IsSuccess || loadAccountResult.Value == null) { return loadAccountResult.ToResultOfType(_ => null); } @@ -153,13 +147,13 @@ public class AccountService : ServiceBase, IAccountService { } } - var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache); + var saveResult = await cacheService.SaveToCacheAsync(accountId, cache); if (!saveResult.IsSuccess) { return saveResult.ToResultOfType(default); } if (hostnamesToAdd.Count > 0) { - var fullFlowResult = await _certsFlowService.FullFlow( + var fullFlowResult = await certsFlowService.FullFlow( cache.IsStaging, cache.AccountId, cache.Description, @@ -173,7 +167,7 @@ public class AccountService : ServiceBase, IAccountService { } if (hostnamesToRemove.Count > 0) { - var revokeResult = await _certsFlowService.FullRevocationFlow( + var revokeResult = await certsFlowService.FullRevocationFlow( cache.IsStaging, cache.AccountId, cache.Description, @@ -186,7 +180,7 @@ public class AccountService : ServiceBase, IAccountService { } #endregion - loadAccountResult = await _cacheService.LoadAccountFromCacheAsync(accountId); + loadAccountResult = await cacheService.LoadAccountFromCacheAsync(accountId); if (!loadAccountResult.IsSuccess || loadAccountResult.Value == null) { return loadAccountResult.ToResultOfType(_ => null); } @@ -198,7 +192,7 @@ public class AccountService : ServiceBase, IAccountService { // TODO: Revoke all certificates // Remove from cache - return await _cacheService.DeleteFromCacheAsync(accountId); + return await cacheService.DeleteAccountCacheAsync(accountId); } #endregion diff --git a/src/LetsEncryptServer/Services/AgentService.cs b/src/MaksIT.Webapi/Services/AgentService.cs similarity index 73% rename from src/LetsEncryptServer/Services/AgentService.cs rename to src/MaksIT.Webapi/Services/AgentService.cs index 63b7cd0..b997a6d 100644 --- a/src/LetsEncryptServer/Services/AgentService.cs +++ b/src/MaksIT.Webapi/Services/AgentService.cs @@ -1,12 +1,13 @@ using MaksIT.Models.Agent.Requests; using MaksIT.Results; +using MaksIT.Webapi.Abstractions.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Models.Agent.Responses; +using MaksIT.Models.Agent.Responses; using System.Text; using System.Text.Json; -namespace MaksIT.LetsEncryptServer.Services { +namespace MaksIT.Webapi.Services { public interface IAgentService { Task> GetHelloWorld(); @@ -14,21 +15,14 @@ namespace MaksIT.LetsEncryptServer.Services { Task ReloadService(string serviceName); } - public class AgentService : IAgentService { - - private readonly Configuration _appSettings; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - - public AgentService( - IOptions appSettings, - ILogger logger, - HttpClient httpClient - ) { - _appSettings = appSettings.Value; - _logger = logger; - _httpClient = httpClient; - } + public class AgentService( + IOptions appSettings, + ILogger logger, + HttpClient httpClient + ) : ServiceBase( + logger, + appSettings + ), IAgentService { public async Task> GetHelloWorld() { try { @@ -39,12 +33,11 @@ namespace MaksIT.LetsEncryptServer.Services { var request = new HttpRequestMessage(HttpMethod.Get, fullAddress); request.Headers.Add("x-api-key", _appSettings.Agent.AgentKey); - _logger.LogInformation($"Sending GET request to {fullAddress}"); + logger.LogInformation($"Sending GET request to {fullAddress}"); - var response = await _httpClient.SendAsync(request); + var response = await httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { - var content = await response.Content.ReadAsStringAsync(); return Result.Ok(new HelloWorldResponse { @@ -52,7 +45,7 @@ namespace MaksIT.LetsEncryptServer.Services { }); } else { - _logger.LogError($"Request to {endpoint} failed with status code: {response.StatusCode}"); + logger.LogError($"Request to {endpoint} failed with status code: {response.StatusCode}"); return Result.InternalServerError(null, $"Request to {endpoint} failed with status code: {response.StatusCode}"); } @@ -60,7 +53,7 @@ namespace MaksIT.LetsEncryptServer.Services { catch (Exception ex) { List messages = new() { "Something went wrong" }; - _logger.LogError(ex, messages.FirstOrDefault()); + logger.LogError(ex, messages.FirstOrDefault()); messages.Add(ex.Message); @@ -89,20 +82,20 @@ namespace MaksIT.LetsEncryptServer.Services { request.Headers.Add("x-api-key", _appSettings.Agent.AgentKey); request.Headers.Add("accept", "application/json"); - var response = await _httpClient.SendAsync(request); + var response = await httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { return Result.Ok(); } else { - _logger.LogError($"Request to {endpoint} failed with status code: {response.StatusCode}"); + logger.LogError($"Request to {endpoint} failed with status code: {response.StatusCode}"); return Result.InternalServerError($"Request to {endpoint} failed with status code: {response.StatusCode}"); } } catch (Exception ex) { List messages = new() { "Something went wrong" }; - _logger.LogError(ex, messages.FirstOrDefault()); + logger.LogError(ex, messages.FirstOrDefault()); messages.Add(ex.Message); diff --git a/src/LetsEncryptServer/Services/CacheService.cs b/src/MaksIT.Webapi/Services/CacheService.cs similarity index 53% rename from src/LetsEncryptServer/Services/CacheService.cs rename to src/MaksIT.Webapi/Services/CacheService.cs index 6f53eab..7e54a22 100644 --- a/src/LetsEncryptServer/Services/CacheService.cs +++ b/src/MaksIT.Webapi/Services/CacheService.cs @@ -1,145 +1,95 @@ -using MaksIT.Core.Extensions; +using System.IO.Compression; +using Microsoft.Extensions.Options; +using MaksIT.Core.Extensions; using MaksIT.Core.Threading; using MaksIT.LetsEncrypt.Entities; using MaksIT.Results; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.IO.Compression; -using System.Text.Json; +using MaksIT.Webapi.Abstractions.Services; -namespace MaksIT.LetsEncryptServer.Services; + +namespace MaksIT.Webapi.Services; public interface ICacheService { Task> LoadAccountsFromCacheAsync(); Task> LoadAccountFromCacheAsync(Guid accountId); Task SaveToCacheAsync(Guid accountId, RegistrationCache cache); - Task DeleteFromCacheAsync(Guid accountId); - Task> DownloadCacheZipAsync(); Task> DownloadAccountCacheZipAsync(Guid accountId); Task UploadCacheZipAsync(byte[] zipBytes); Task UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes); - Result DeleteCacheAsync(); + Task DeleteCacheAsync(); + Task DeleteAccountCacheAsync(Guid accountId); } -public class CacheService : ICacheService, IDisposable { - private readonly ILogger _logger; - private readonly string _cacheDirectory; - private readonly LockManager _lockManager; +public class CacheService( + ILogger logger, + IOptions appSettings +) : ServiceBase( + logger, + appSettings +), ICacheService, IDisposable { + private readonly string _cacheDirectory = appSettings.Value.CacheFolder; + private readonly LockManager _lockManager = new(); private readonly string tmpDir = "/tmp"; - public CacheService( - ILogger logger, - IOptions appsettings - ) { - _logger = logger; - _cacheDirectory = appsettings.Value.CacheFolder; - _lockManager = new LockManager(); - } - - /// - /// Generates the cache file path for the given account ID. - /// - private string GetCacheFilePath(Guid accountId) { - return Path.Combine(_cacheDirectory, $"{accountId}.json"); - } - - private Guid[] GetCachedAccounts() { - return GetCacheFilesPaths().Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).Where(x => x != Guid.Empty).ToArray(); - } - - private string[] GetCacheFilesPaths() { - return Directory.GetFiles(_cacheDirectory); - } - - #region Cache Operations - public async Task> LoadAccountsFromCacheAsync() { return await _lockManager.ExecuteWithLockAsync(async () => { var accountIds = GetCachedAccounts(); - var cacheLoadTasks = accountIds.Select(accountId => LoadFromCacheInternalAsync(accountId)).ToList(); - var caches = new List(); - foreach (var task in cacheLoadTasks) { - var taskResult = await task; - if (!taskResult.IsSuccess || taskResult.Value == null) { - // Depending on how you want to handle partial failures, you might want to return here - // or continue loading other caches. For now, let's continue. + foreach (var accountId in accountIds) { + var cacheFilePath = GetCacheFilePath(accountId); + if (!File.Exists(cacheFilePath)) { + logger.LogWarning($"Cache file not found for account {accountId}"); continue; } - - var registrationCache = taskResult.Value; - - caches.Add(registrationCache); + var json = await File.ReadAllTextAsync(cacheFilePath); + if (string.IsNullOrEmpty(json)) { + logger.LogWarning($"Cache file is empty for account {accountId}"); + continue; + } + var cache = json.ToObject(); + if (cache != null) + caches.Add(cache); } - return Result.Ok(caches.ToArray()); }); } - private async Task> LoadFromCacheInternalAsync(Guid accountId) { - var cacheFilePath = GetCacheFilePath(accountId); - - if (!File.Exists(cacheFilePath)) { - var message = $"Cache file not found for account {accountId}"; - _logger.LogWarning(message); - return Result.InternalServerError(null, message); - } - - var json = await File.ReadAllTextAsync(cacheFilePath); - if (string.IsNullOrEmpty(json)) { - var message = $"Cache file is empty for account {accountId}"; - _logger.LogWarning(message); - return Result.InternalServerError(null, message); - } - - var cache = json.ToObject(); - return Result.Ok(cache); + public async Task> LoadAccountFromCacheAsync(Guid accountId) { + return await _lockManager.ExecuteWithLockAsync(async () => { + var cacheFilePath = GetCacheFilePath(accountId); + if (!File.Exists(cacheFilePath)) { + var message = $"Cache file not found for account {accountId}"; + logger.LogWarning(message); + return Result.InternalServerError(null, message); + } + var json = await File.ReadAllTextAsync(cacheFilePath); + if (string.IsNullOrEmpty(json)) { + var message = $"Cache file is empty for account {accountId}"; + logger.LogWarning(message); + return Result.InternalServerError(null, message); + } + var cache = json.ToObject(); + return Result.Ok(cache); + }); } - private async Task SaveToCacheInternalAsync(Guid accountId, RegistrationCache cache) { - var cacheFilePath = GetCacheFilePath(accountId); - var json = cache.ToJson(); - await File.WriteAllTextAsync(cacheFilePath, json); - _logger.LogInformation($"Cache file saved for account {accountId}"); - return Result.Ok(); - } - - private Result DeleteFromCacheInternal(Guid accountId) { - var cacheFilePath = GetCacheFilePath(accountId); - if (File.Exists(cacheFilePath)) { - File.Delete(cacheFilePath); - _logger.LogInformation($"Cache file deleted for account {accountId}"); - } - else { - _logger.LogWarning($"Cache file not found for account {accountId}"); - } - return Result.Ok(); - } - - #endregion - - - #region - private string GetTempZipPath(string prefix) - { - var zipName = $"{prefix}_{DateTime.UtcNow:yyyyMMddHHmmss}.zip"; - return Path.Combine(tmpDir, zipName); - } - - private void EnsureTempDirAndDeleteFile(string filePath) - { - Directory.CreateDirectory(tmpDir); - if (File.Exists(filePath)) - File.Delete(filePath); + public async Task SaveToCacheAsync(Guid accountId, RegistrationCache cache) { + return await _lockManager.ExecuteWithLockAsync(async () => { + var cacheFilePath = GetCacheFilePath(accountId); + var json = cache.ToJson(); + await File.WriteAllTextAsync(cacheFilePath, json); + logger.LogInformation($"Cache file saved for account {accountId}"); + return Result.Ok(); + }); } public async Task> DownloadCacheZipAsync() { try { if (!Directory.Exists(_cacheDirectory)) { var message = "Cache directory not found."; - _logger.LogWarning(message); + logger.LogWarning(message); return Result.NotFound(null, message); } @@ -148,12 +98,12 @@ public class CacheService : ICacheService, IDisposable { ZipFile.CreateFromDirectory(_cacheDirectory, zipPath); var zipBytes = await File.ReadAllBytesAsync(zipPath); File.Delete(zipPath); - _logger.LogInformation("Cache zipped to {ZipPath}", zipPath); + logger.LogInformation("Cache zipped to {ZipPath}", zipPath); return Result.Ok(zipBytes); } catch (Exception ex) { var message = "Error creating or reading cache zip file."; - _logger.LogError(ex, message); + logger.LogError(ex, message); return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); } } @@ -163,7 +113,7 @@ public class CacheService : ICacheService, IDisposable { var cacheFilePath = GetCacheFilePath(accountId); if (!File.Exists(cacheFilePath)) { var message = $"Cache file not found for account {accountId}."; - _logger.LogWarning(message); + logger.LogWarning(message); return Result.NotFound(null, message); } var zipPath = GetTempZipPath($"account_cache_{accountId}"); @@ -173,12 +123,12 @@ public class CacheService : ICacheService, IDisposable { } var zipBytes = await File.ReadAllBytesAsync(zipPath); File.Delete(zipPath); - _logger.LogInformation("Account cache zipped to {ZipPath}", zipPath); + logger.LogInformation("Account cache zipped to {ZipPath}", zipPath); return Result.Ok(zipBytes); } catch (Exception ex) { var message = "Error creating or reading account cache zip file."; - _logger.LogError(ex, message); + logger.LogError(ex, message); return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); } } @@ -190,12 +140,12 @@ public class CacheService : ICacheService, IDisposable { await File.WriteAllBytesAsync(zipPath, zipBytes); ZipFile.ExtractToDirectory(zipPath, _cacheDirectory, true); File.Delete(zipPath); - _logger.LogInformation("Cache unzipped from {ZipPath}", zipPath); + logger.LogInformation("Cache unzipped from {ZipPath}", zipPath); return Result.Ok(); } catch (Exception ex) { var message = "Error uploading or extracting cache zip file."; - _logger.LogError(ex, message); + logger.LogError(ex, message); return Result.InternalServerError([message, .. ex.ExtractMessages()]); } } @@ -212,55 +162,85 @@ public class CacheService : ICacheService, IDisposable { } } File.Delete(zipPath); - _logger.LogInformation("Account cache unzipped from {ZipPath}", zipPath); + logger.LogInformation("Account cache unzipped from {ZipPath}", zipPath); return Result.Ok(); } catch (Exception ex) { var message = "Error uploading or extracting account cache zip file."; - _logger.LogError(ex, message); + logger.LogError(ex, message); return Result.InternalServerError([message, .. ex.ExtractMessages()]); } } - public Result DeleteCacheAsync() { - try { - if (Directory.Exists(_cacheDirectory)) { - // Delete all files - foreach (var file in Directory.GetFiles(_cacheDirectory)) { - File.Delete(file); + public async Task DeleteCacheAsync() { + return await _lockManager.ExecuteWithLockAsync(() => { + try { + if (Directory.Exists(_cacheDirectory)) { + // Delete all files + foreach (var file in Directory.GetFiles(_cacheDirectory)) { + File.Delete(file); + } + // Delete all subdirectories + foreach (var dir in Directory.GetDirectories(_cacheDirectory)) { + Directory.Delete(dir, true); + } + logger.LogInformation("Cache directory contents cleared."); } - // Delete all subdirectories - foreach (var dir in Directory.GetDirectories(_cacheDirectory)) { - Directory.Delete(dir, true); + else { + logger.LogWarning("Cache directory not found to clear."); } - _logger.LogInformation("Cache directory contents cleared."); + return Result.Ok(); + } + catch (Exception ex) { + var message = "Error clearing cache directory contents."; + logger.LogError(ex, message); + return Result.InternalServerError([message, .. ex.ExtractMessages()]); + } + }); + } + + public async Task DeleteAccountCacheAsync(Guid accountId) { + return await _lockManager.ExecuteWithLockAsync(() => { + var cacheFilePath = GetCacheFilePath(accountId); + if (File.Exists(cacheFilePath)) { + File.Delete(cacheFilePath); + logger.LogInformation($"Cache file deleted for account {accountId}"); } else { - _logger.LogWarning("Cache directory not found to clear."); + logger.LogWarning($"Cache file not found for account {accountId}"); } - return Result.Ok(); - } - catch (Exception ex) { - var message = "Error clearing cache directory contents."; - _logger.LogError(ex, message); - return Result.InternalServerError([message, .. ex.ExtractMessages()]); - } + return Task.FromResult(Result.Ok()); + }); } + #region Helpers + /// + /// Generates the cache file path for the given account ID. + /// + private string GetCacheFilePath(Guid accountId) { + return Path.Combine(_cacheDirectory, $"{accountId}.json"); + } + + private Guid[] GetCachedAccounts() { + return GetCacheFilesPaths().Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).Where(x => x != Guid.Empty).ToArray(); + } + + private string[] GetCacheFilesPaths() { + return Directory.GetFiles(_cacheDirectory); + } + + private string GetTempZipPath(string prefix) { + var zipName = $"{prefix}_{DateTime.UtcNow:yyyyMMddHHmmss}.zip"; + return Path.Combine(tmpDir, zipName); + } + + private void EnsureTempDirAndDeleteFile(string filePath) { + Directory.CreateDirectory(tmpDir); + if (File.Exists(filePath)) + File.Delete(filePath); + } #endregion - public async Task> LoadAccountFromCacheAsync(Guid accountId) { - return await _lockManager.ExecuteWithLockAsync(() => LoadFromCacheInternalAsync(accountId)); - } - - public async Task SaveToCacheAsync(Guid accountId, RegistrationCache cache) { - return await _lockManager.ExecuteWithLockAsync(() => SaveToCacheInternalAsync(accountId, cache)); - } - - public async Task DeleteFromCacheAsync(Guid accountId) { - return await _lockManager.ExecuteWithLockAsync(() => DeleteFromCacheInternal(accountId)); - } - public void Dispose() { _lockManager.Dispose(); } diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/MaksIT.Webapi/Services/CertsFlowService.cs similarity index 68% rename from src/LetsEncryptServer/Services/CertsFlowService.cs rename to src/MaksIT.Webapi/Services/CertsFlowService.cs index 7fb7edc..526139b 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/MaksIT.Webapi/Services/CertsFlowService.cs @@ -3,9 +3,10 @@ using MaksIT.Results; using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities.LetsEncrypt; using MaksIT.LetsEncrypt.Services; +using MaksIT.Webapi.Abstractions.Services; -namespace MaksIT.LetsEncryptServer.Services; +namespace MaksIT.Webapi.Services; public interface ICertsFlowService { Result GetTermsOfService(Guid sessionId); @@ -22,77 +23,62 @@ public interface ICertsFlowService { Result AcmeChallenge(string fileName); } -public class CertsFlowService : ICertsFlowService { - private readonly Configuration _appSettings; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - private readonly ILetsEncryptService _letsEncryptService; - private readonly ICacheService _cacheService; - private readonly IAgentService _agentService; - private readonly string _acmePath; +public class CertsFlowService( + IOptions appSettings, + ILogger logger, + HttpClient httpClient, + ILetsEncryptService letsEncryptService, + ICacheService cacheService, + IAgentService agentService +) : ServiceBase( + logger, + appSettings +), ICertsFlowService { - public CertsFlowService( - IOptions appSettings, - ILogger logger, - HttpClient httpClient, - ILetsEncryptService letsEncryptService, - ICacheService cashService, - IAgentService agentService - ) { - _appSettings = appSettings.Value; - _logger = logger; - _httpClient = httpClient; - _letsEncryptService = letsEncryptService; - _cacheService = cashService; - _agentService = agentService; - _acmePath = _appSettings.AcmeFolder; - } + private readonly string _acmePath = appSettings.Value.AcmeFolder; public Result GetTermsOfService(Guid sessionId) { - var result = _letsEncryptService.GetTermsOfServiceUri(sessionId); + var result = letsEncryptService.GetTermsOfServiceUri(sessionId); if (!result.IsSuccess || result.Value == null) return result; var termsOfServiceUrl = result.Value; try { - 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 */ } - } + var fileName = Path.GetFileName(new Uri(termsOfServiceUrl).LocalPath); + var termsOfServicePdfPath = Path.Combine(_appSettings.DataFolder, fileName); + 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); + } + 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, cache, or convert Terms of Service PDF"); + 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}"); } } public async Task CompleteChallengesAsync(Guid sessionId) { - return await _letsEncryptService.CompleteChallenges(sessionId); + return await letsEncryptService.CompleteChallenges(sessionId); } public async Task> ConfigureClientAsync(bool isStaging) { var sessionId = Guid.NewGuid(); - var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging); + var result = await letsEncryptService.ConfigureClient(sessionId, isStaging); if (!result.IsSuccess) return result.ToResultOfType(default); return Result.Ok(sessionId); @@ -100,13 +86,11 @@ public class CertsFlowService : ICertsFlowService { public async Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) { RegistrationCache? cache = null; - if (accountId == null) { accountId = Guid.NewGuid(); } else { - var cacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId.Value); - + var cacheResult = await cacheService.LoadAccountFromCacheAsync(accountId.Value); if (!cacheResult.IsSuccess || cacheResult.Value == null) { accountId = Guid.NewGuid(); } @@ -114,94 +98,76 @@ public class CertsFlowService : ICertsFlowService { cache = cacheResult.Value; } } - - var result = await _letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache); + var result = await letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache); if (!result.IsSuccess) return result.ToResultOfType(default); - return Result.Ok(accountId.Value); } public async Task?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType) { - var orderResult = await _letsEncryptService.NewOrder(sessionId, hostnames, challengeType); + var orderResult = await letsEncryptService.NewOrder(sessionId, hostnames, challengeType); if (!orderResult.IsSuccess || orderResult.Value == null) return orderResult.ToResultOfType?>(_ => null); - var challenges = new List(); - foreach (var kvp in orderResult.Value) { string[] splitToken = kvp.Value.Split('.'); File.WriteAllText(Path.Combine(_acmePath, splitToken[0]), kvp.Value); challenges.Add(splitToken[0]); } - return Result?>.Ok(challenges); } public async Task GetCertificatesAsync(Guid sessionId, string[] hostnames) { foreach (var subject in hostnames) { - var result = await _letsEncryptService.GetCertificate(sessionId, subject); + var result = await letsEncryptService.GetCertificate(sessionId, subject); if (!result.IsSuccess) return result; Thread.Sleep(1000); } - - var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId); + var cacheResult = letsEncryptService.GetRegistrationCache(sessionId); if (!cacheResult.IsSuccess || cacheResult.Value == null) return cacheResult; - - var saveResult = await _cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); + var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); if (!saveResult.IsSuccess) return saveResult; - return Result.Ok(); } public async Task GetOrderAsync(Guid sessionId, string[] hostnames) { - return await _letsEncryptService.GetOrder(sessionId, hostnames); + return await letsEncryptService.GetOrder(sessionId, hostnames); } public async Task?>> ApplyCertificatesAsync(Guid accountId) { - var cacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId); + var cacheResult = await cacheService.LoadAccountFromCacheAsync(accountId); if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null) return cacheResult.ToResultOfType?>(_ => null); - var cache = cacheResult.Value; var results = cache.GetCertsPemPerHostname(); - - if (cache.IsDisabled) return Result?>.BadRequest(null, $"Account {accountId} is disabled"); - if (cache.IsStaging) return Result?>.UnprocessableEntity(null, $"Found certs for {string.Join(',', results.Keys)} (staging environment)"); - - var uploadResult = await _agentService.UploadCerts(results); + var uploadResult = await agentService.UploadCerts(results); if (!uploadResult.IsSuccess) return uploadResult.ToResultOfType?>(default); - - var reloadResult = await _agentService.ReloadService(_appSettings.Agent.ServiceToReload); + var reloadResult = await agentService.ReloadService(_appSettings.Agent.ServiceToReload); if (!reloadResult.IsSuccess) return reloadResult.ToResultOfType?>(default); - return Result?>.Ok(results); } public async Task RevokeCertificatesAsync(Guid sessionId, string[] hostnames) { foreach (var hostname in hostnames) { - var result = await _letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified); + var result = await letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified); if (!result.IsSuccess) return result; } - - var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId); + var cacheResult = letsEncryptService.GetRegistrationCache(sessionId); if (!cacheResult.IsSuccess || cacheResult.Value == null) return cacheResult; - - var saveResult = await _cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); + var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); if (!saveResult.IsSuccess) return saveResult; - return Result.Ok(); } @@ -209,40 +175,31 @@ public class CertsFlowService : ICertsFlowService { var sessionResult = await ConfigureClientAsync(isStaging); if (!sessionResult.IsSuccess || sessionResult.Value == null) return sessionResult; - var sessionId = sessionResult.Value.Value; - var initResult = await InitAsync(sessionId, accountId, description, contacts); 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); - if (challengesResult.Value?.Count > 0) { var challengeResult = await CompleteChallengesAsync(sessionId); if (!challengeResult.IsSuccess) return challengeResult.ToResultOfType(default); } - var getOrderResult = await GetOrderAsync(sessionId, hostnames); if (!getOrderResult.IsSuccess) return getOrderResult.ToResultOfType(default); - var certsResult = await GetCertificatesAsync(sessionId, hostnames); if (!certsResult.IsSuccess) return certsResult.ToResultOfType(default); - if (!isStaging) { var applyCertsResult = await ApplyCertificatesAsync(accountId.Value); if (!applyCertsResult.IsSuccess) return applyCertsResult.ToResultOfType(_ => null); } - return Result.Ok(initResult.Value); } @@ -250,27 +207,21 @@ public class CertsFlowService : ICertsFlowService { var sessionResult = await ConfigureClientAsync(isStaging); if (!sessionResult.IsSuccess || sessionResult.Value == null) return sessionResult; - var sessionId = sessionResult.Value.Value; - var initResult = await InitAsync(sessionId, accountId, description, contacts); if (!initResult.IsSuccess) return initResult; - var revokeResult = await RevokeCertificatesAsync(sessionId, hostnames); if (!revokeResult.IsSuccess) return revokeResult; - return Result.Ok(); } public Result AcmeChallenge(string fileName) { DeleteExporedChallenges(); - var challengePath = Path.Combine(_acmePath, fileName); if (!File.Exists(challengePath)) return Result.NotFound(null); - var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName)); return Result.Ok(fileContent); } @@ -281,15 +232,13 @@ public class CertsFlowService : ICertsFlowService { try { var creationTime = File.GetCreationTime(file); var timeDifference = currentDate - creationTime; - - if (timeDifference.TotalDays > 1) { File.Delete(file); - _logger.LogInformation($"Deleted file: {file}"); + logger.LogInformation($"Deleted file: {file}"); } } catch (Exception ex) { - _logger.LogWarning(ex, "File cannot be deleted"); + logger.LogWarning(ex, "File cannot be deleted"); } } } diff --git a/src/LetsEncryptServer/Services/IdentityService.cs b/src/MaksIT.Webapi/Services/IdentityService.cs similarity index 60% rename from src/LetsEncryptServer/Services/IdentityService.cs rename to src/MaksIT.Webapi/Services/IdentityService.cs index b619e71..b11bba2 100644 --- a/src/LetsEncryptServer/Services/IdentityService.cs +++ b/src/MaksIT.Webapi/Services/IdentityService.cs @@ -1,16 +1,24 @@ -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.Linq.Dynamic.Core.Tokenizer; -using System.Security.Claims; +using MaksIT.Results; +using MaksIT.Webapi.Domain; +using MaksIT.Webapi.Authorization; +using MaksIT.Webapi.Abstractions.Services; +using MaksIT.Core.Security.JWT; +using MaksIT.Core.Webapi.Models; +using MaksIT.Models.LetsEncryptServer.Identity.Login; +using MaksIT.Models.LetsEncryptServer.Identity.Logout; +using MaksIT.Models.LetsEncryptServer.Identity.User; -namespace MaksIT.LetsEncryptServer.Services; +namespace MaksIT.Webapi.Services; public interface IIdentityService { + + #region Patch + Task> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData); + #endregion + #region Login/Refresh/Logout Task> LoginAsync(LoginRequest requestData); Task> RefreshTokenAsync(RefreshTokenRequest requestData); @@ -19,12 +27,53 @@ public interface IIdentityService { } public class IdentityService( - IOptions appsettings, + ILogger logger, + IOptions appSettings, ISettingsService settingsService -) : IIdentityService { +) : ServiceBase(logger, appSettings), IIdentityService { + #region Patch + public async Task> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData) { + var loadSettingsResult = await settingsService.LoadAsync(); + if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) { + return loadSettingsResult.ToResultOfType(_ => null); + } + + var settings = loadSettingsResult.Value; + + var userResult = settings.GetUserById(id); + if (!userResult.IsSuccess || userResult.Value == null) + return userResult.ToResultOfType(_ => null); + + var user = userResult.Value; + + #region Authentication properties + if (requestData.TryGetOperation(nameof(requestData.Password), out var patchOperation)) { + switch (patchOperation) { + case PatchOperation.SetField: + if (requestData.Password == null) + return PatchFieldIsNotDefined(nameof(requestData.Password)); + user.SetPassword(requestData.Password, _appSettings.Auth.Pepper); + break; + default: + return UnsupportedPatchOperationResponse(); + } + } + #endregion + + settings.UpsertUser(user); + + var saveSettingsResult = await settingsService.SaveAsync(settings); + if (!saveSettingsResult.IsSuccess) + return saveSettingsResult.ToResultOfType(default); + + return Result.Ok(new UserResponse { + + }); + + } + #endregion - private readonly Configuration _appSettings = appsettings.Value; #region Login/Refresh/Logout public async Task> LoginAsync(LoginRequest requestData) { @@ -90,59 +139,59 @@ public class IdentityService( public async Task> RefreshTokenAsync(RefreshTokenRequest requestData) { var loadSettingsResult = await settingsService.LoadAsync(); if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) - return loadSettingsResult.ToResultOfType(_ => 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."); + return Result.Unauthorized(null, "Invalid refresh token."); var user = userResult.Value.RemoveRevokedJwtTokens(); var tokenDomain = user.JwtTokens.SingleOrDefault(t => t.RefreshToken == requestData.RefreshToken); if (tokenDomain == null) - return Result.Unauthorized(null, "Invalid refresh token."); + return Result.Unauthorized(null, "Invalid refresh token."); // Token is still valid if (DateTime.UtcNow <= tokenDomain.ExpiresAt) { - user.SetLastLogin(); - settings.UpsertUser(user); + user.SetLastLogin(); + settings.UpsertUser(user); - var saveResult = await settingsService.SaveAsync(settings); - if (!saveResult.IsSuccess) - return saveResult.ToResultOfType(default); + var saveResult = await settingsService.SaveAsync(settings); + if (!saveResult.IsSuccess) + return saveResult.ToResultOfType(default); - return Result.Ok(new LoginResponse { - TokenType = tokenDomain.TokenType, - Token = tokenDomain.Token, - ExpiresAt = tokenDomain.ExpiresAt, - RefreshToken = tokenDomain.RefreshToken, - RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt - }); + return Result.Ok(new LoginResponse { + TokenType = tokenDomain.TokenType, + Token = tokenDomain.Token, + ExpiresAt = tokenDomain.ExpiresAt, + RefreshToken = tokenDomain.RefreshToken, + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt + }); } // Refresh token expired if (DateTime.UtcNow > tokenDomain.RefreshTokenExpiresAt) { - user.RemoveJwtToken(tokenDomain.Id); - return Result.Unauthorized(null, "Refresh token has expired."); + 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, + 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); + 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."); + return Result.InternalServerError(null, "Token claims are missing required fields."); string refreshToken = JwtGenerator.GenerateRefreshToken(); @@ -156,14 +205,14 @@ public class IdentityService( var writeResult = await settingsService.SaveAsync(settings); if (!writeResult.IsSuccess) - return writeResult.ToResultOfType(default); + 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 + TokenType = tokenDomain.TokenType, + Token = tokenDomain.Token, + ExpiresAt = claims.ExpiresAt.Value, + RefreshToken = tokenDomain.RefreshToken, + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt }); } diff --git a/src/MaksIT.Webapi/Services/SettingsService.cs b/src/MaksIT.Webapi/Services/SettingsService.cs new file mode 100644 index 0000000..e4d548a --- /dev/null +++ b/src/MaksIT.Webapi/Services/SettingsService.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.Options; +using MaksIT.Results; +using MaksIT.Core.Extensions; +using MaksIT.Core.Threading; +using MaksIT.Webapi.Domain; +using MaksIT.Webapi.Dto; +using MaksIT.Webapi.Abstractions.Services; + + +namespace MaksIT.Webapi.Services; + +public interface ISettingsService { + Task> LoadAsync(); + Task SaveAsync(Settings settings); +} + +public class SettingsService( + ILogger logger, + IOptions appSettings +) : ServiceBase(logger, appSettings), ISettingsService, IDisposable { + + private readonly LockManager _lockManager = new LockManager(); + private readonly string _settingsPath = appSettings.Value.SettingsFile; + + #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) + .SetName(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) { + var message = "Error loading settings file."; + _logger.LogError(ex, message); + return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); + } + } + + private async Task SaveInternalAsync(Settings settings) { + try { + 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() { + 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.Development.json b/src/MaksIT.Webapi/appsettings.Development.json similarity index 100% rename from src/LetsEncryptServer/appsettings.Development.json rename to src/MaksIT.Webapi/appsettings.Development.json diff --git a/src/LetsEncryptServer/appsettings.json b/src/MaksIT.Webapi/appsettings.json similarity index 63% rename from src/LetsEncryptServer/appsettings.json rename to src/MaksIT.Webapi/appsettings.json index b88ded1..39904c7 100644 --- a/src/LetsEncryptServer/appsettings.json +++ b/src/MaksIT.Webapi/appsettings.json @@ -8,31 +8,29 @@ "AllowedHosts": "*", "Configuration": { - "SettingsFile": "/data/settings.json", + "Auth": { "Secret": "", - "Issuer": "", - "Audience": "", - "Expiration": 60, - "RefreshExpiration": 120, - - - "Pepper": "" + "Pepper": "", + "Issuer": "LetsEncryptServer", + "Audience": "LetsEncryptServerUsers", + "Expiration": 15, // Access token lifetime in minutes (default: 15 minutes) + "RefreshExpiration": 180 // Refresh token lifetime in days (default: 180 days) }, - "Production": "https://acme-v02.api.letsencrypt.org/directory", - "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", - - "CacheFolder": "/cache", - "AcmeFolder": "/acme", - "DataFolder": "/data", - "Agent": { "AgentHostname": "", "AgentPort": 9000, "AgentKey": "", - "ServiceToReload": "haproxy" - } + }, + + "Production": "https://acme-v02.api.letsencrypt.org/directory", + "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", + "AcmeFolder": "/acme", + "CacheFolder": "/cache", + + "DataFolder": "/data", + "SettingsFile": "/data/settings.json" } } diff --git a/src/Models/Agent/Requests/CertsUploadRequest.cs b/src/Models/Agent/Requests/CertsUploadRequest.cs index d2dbb7c..c732b40 100644 --- a/src/Models/Agent/Requests/CertsUploadRequest.cs +++ b/src/Models/Agent/Requests/CertsUploadRequest.cs @@ -1,7 +1,8 @@ -namespace MaksIT.Models.Agent.Requests { - public class CertsUploadRequest { +using MaksIT.Core.Abstractions.Webapi; - public Dictionary Certs { get; set; } - } +namespace MaksIT.Models.Agent.Requests; + +public class CertsUploadRequest : RequestModelBase { + public Dictionary Certs { get; set; } } diff --git a/src/Models/Agent/Requests/ServiceReloadRequest.cs b/src/Models/Agent/Requests/ServiceReloadRequest.cs index 9921ba9..bbff92c 100644 --- a/src/Models/Agent/Requests/ServiceReloadRequest.cs +++ b/src/Models/Agent/Requests/ServiceReloadRequest.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using MaksIT.Core.Abstractions.Webapi; -namespace MaksIT.Models.Agent.Requests { - public class ServiceReloadRequest { - public string ServiceName { get; set; } - } + +namespace MaksIT.Models.Agent.Requests; + +public class ServiceReloadRequest : RequestModelBase { + public string ServiceName { get; set; } } diff --git a/src/Models/Agent/Responses/HelloWorldResponse.cs b/src/Models/Agent/Responses/HelloWorldResponse.cs index c9cfdbc..3f46d89 100644 --- a/src/Models/Agent/Responses/HelloWorldResponse.cs +++ b/src/Models/Agent/Responses/HelloWorldResponse.cs @@ -1,10 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using MaksIT.Core.Abstractions.Webapi; -namespace Models.Agent.Responses; -public class HelloWorldResponse { + +namespace MaksIT.Models.Agent.Responses; + +public class HelloWorldResponse : ResponseModelBase { public string Message { get; set; } } diff --git a/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs index a322f6c..97c60ab 100644 --- a/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs +++ b/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs @@ -4,12 +4,8 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests; public class PatchAccountRequest : PatchRequestModelBase { - public string? Description { get; set; } - public bool? IsDisabled { get; set; } - public List? Contacts { get; set; } - public List? Hostnames { get; set; } } diff --git a/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs index 3d72f48..9245a5b 100644 --- a/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs +++ b/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs @@ -2,9 +2,9 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests; + public class PatchHostnameRequest : PatchRequestModelBase { public string? Hostname { get; set; } - public bool? IsDisabled { get; set; } } diff --git a/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs index dd8eb7d..a816ef1 100644 --- a/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs +++ b/src/Models/LetsEncryptServer/Account/Requests/PostAccountRequest.cs @@ -1,7 +1,8 @@ -using MaksIT.Core.Abstractions.Webapi; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using MaksIT.Core.Abstractions.Webapi; namespace MaksIT.Models.LetsEncryptServer.Account.Requests; + public class PostAccountRequest : RequestModelBase { public required string Description { get; set; } public required string[] Contacts { get; set; } @@ -11,15 +12,15 @@ public class PostAccountRequest : RequestModelBase { public override IEnumerable Validate(ValidationContext validationContext) { if (string.IsNullOrWhiteSpace(Description)) - yield return new ValidationResult("Description is required", new[] { nameof(Description) }); + yield return new ValidationResult("Description is required", [nameof(Description)]); if (Contacts == null || Contacts.Length == 0) - yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) }); + yield return new ValidationResult("Contacts is required", [nameof(Contacts)]); if (Hostnames == null || Hostnames.Length == 0) - yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); + yield return new ValidationResult("Hostnames is required", [nameof(Hostnames)]); if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01") - yield return new ValidationResult("ChallengeType is required", new[] { nameof(ChallengeType) }); + yield return new ValidationResult("ChallengeType is required", [nameof(ChallengeType)]); } } diff --git a/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs b/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs index 1c5d5e5..ef8d586 100644 --- a/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs +++ b/src/Models/LetsEncryptServer/Account/Responses/GetAccountResponse.cs @@ -1,23 +1,14 @@ using MaksIT.Core.Abstractions.Webapi; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.Models.LetsEncryptServer.Account.Responses { - public class GetAccountResponse : ResponseModelBase { - public Guid AccountId { get; set; } - public required bool IsDisabled { get; set; } - public string? Description { get; set; } +namespace MaksIT.Models.LetsEncryptServer.Account.Responses; - public required string[] Contacts { get; set; } - - public string? ChallengeType { get; set; } - - public GetHostnameResponse[]? Hostnames { get; set; } - - public required bool IsStaging { get; set; } - } +public class GetAccountResponse : ResponseModelBase { + public Guid AccountId { get; set; } + public required bool IsDisabled { get; set; } + public string? Description { get; set; } + public required string[] Contacts { get; set; } + public string? ChallengeType { get; set; } + public GetHostnameResponse[]? Hostnames { get; set; } + public required bool IsStaging { get; set; } } diff --git a/src/Models/LetsEncryptServer/Account/Responses/GetHostnameResponse.cs b/src/Models/LetsEncryptServer/Account/Responses/GetHostnameResponse.cs index 4067c05..3080d31 100644 --- a/src/Models/LetsEncryptServer/Account/Responses/GetHostnameResponse.cs +++ b/src/Models/LetsEncryptServer/Account/Responses/GetHostnameResponse.cs @@ -1,16 +1,11 @@ using MaksIT.Core.Abstractions.Webapi; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.Models.LetsEncryptServer.Account.Responses { - public class GetHostnameResponse : ResponseModelBase { - public required string Hostname { get; set; } - public DateTime Expires { get; set; } - public bool IsUpcomingExpire { get; set; } - public bool IsDisabled { get; set; } - } +namespace MaksIT.Models.LetsEncryptServer.Account.Responses; + +public class GetHostnameResponse : ResponseModelBase { + public required string Hostname { get; set; } + public DateTime Expires { get; set; } + public bool IsUpcomingExpire { get; set; } + public bool IsDisabled { get; set; } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs index 938bcb8..f955c27 100644 --- a/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/ConfigureClientRequest.cs @@ -1,12 +1,8 @@ using MaksIT.Core.Abstractions.Webapi; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests { - public class ConfigureClientRequest : RequestModelBase { - public bool IsStaging { get; set; } - } + +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; + +public class ConfigureClientRequest : RequestModelBase { + public bool IsStaging { get; set; } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/GetCerificatesRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/GetCerificatesRequest.cs index afb9494..58b80c5 100644 --- a/src/Models/LetsEncryptServer/CertsFlow/Requests/GetCerificatesRequest.cs +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/GetCerificatesRequest.cs @@ -1,14 +1,14 @@ -using MaksIT.Core.Abstractions.Webapi; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using MaksIT.Core.Abstractions.Webapi; -namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests -{ - public class GetCertificatesRequest : RequestModelBase { - public required string[] Hostnames { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) { - if (Hostnames == null || Hostnames.Length == 0) - yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); - } +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; + +public class GetCertificatesRequest : RequestModelBase { + public required string[] Hostnames { get; set; } + + public override IEnumerable Validate(ValidationContext validationContext) { + if (Hostnames == null || Hostnames.Length == 0) + yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/GetOrderRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/GetOrderRequest.cs index b610987..7225759 100644 --- a/src/Models/LetsEncryptServer/CertsFlow/Requests/GetOrderRequest.cs +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/GetOrderRequest.cs @@ -1,14 +1,14 @@ -using MaksIT.Core.Abstractions.Webapi; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using MaksIT.Core.Abstractions.Webapi; -namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests -{ - public class GetOrderRequest : RequestModelBase { - public required string[] Hostnames { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) { - if (Hostnames == null || Hostnames.Length == 0) - yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); - } +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; + +public class GetOrderRequest : RequestModelBase { + public required string[] Hostnames { get; set; } + + public override IEnumerable Validate(ValidationContext validationContext) { + if (Hostnames == null || Hostnames.Length == 0) + yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/InitRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/InitRequest.cs index f85dc53..4062946 100644 --- a/src/Models/LetsEncryptServer/CertsFlow/Requests/InitRequest.cs +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/InitRequest.cs @@ -1,22 +1,18 @@ -using MaksIT.Core.Abstractions.Webapi; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.ComponentModel.DataAnnotations; +using MaksIT.Core.Abstractions.Webapi; -namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests { - public class InitRequest : RequestModelBase { - public required string Description { get; set; } - public required string[] Contacts { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) { - if (string.IsNullOrWhiteSpace(Description)) - yield return new ValidationResult("Description is required", new[] { nameof(Description) }); +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; - if (Contacts == null || Contacts.Length == 0) - yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) }); - } +public class InitRequest : RequestModelBase { + public required string Description { get; set; } + public required string[] Contacts { get; set; } + + public override IEnumerable Validate(ValidationContext validationContext) { + if (string.IsNullOrWhiteSpace(Description)) + yield return new ValidationResult("Description is required", new[] { nameof(Description) }); + + if (Contacts == null || Contacts.Length == 0) + yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) }); } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/NewOrderRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/NewOrderRequest.cs index c6edba4..1aaca4b 100644 --- a/src/Models/LetsEncryptServer/CertsFlow/Requests/NewOrderRequest.cs +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/NewOrderRequest.cs @@ -1,19 +1,18 @@ -using MaksIT.Core.Abstractions.Webapi; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using MaksIT.Core.Abstractions.Webapi; -namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests -{ - public class NewOrderRequest : RequestModelBase { - public required string[] Hostnames { get; set; } - public required string ChallengeType { get; set; } +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; - public override IEnumerable Validate(ValidationContext validationContext) { - if (Hostnames == null || Hostnames.Length == 0) - yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); +public class NewOrderRequest : RequestModelBase { + public required string[] Hostnames { get; set; } + public required string ChallengeType { get; set; } - if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01") - yield return new ValidationResult("ChallengeType is required", new[] { nameof(ChallengeType) }); - } + public override IEnumerable Validate(ValidationContext validationContext) { + if (Hostnames == null || Hostnames.Length == 0) + yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); + + if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01") + yield return new ValidationResult("ChallengeType is required", new[] { nameof(ChallengeType) }); } } diff --git a/src/Models/LetsEncryptServer/CertsFlow/Requests/RevokeCertificatesRequest.cs b/src/Models/LetsEncryptServer/CertsFlow/Requests/RevokeCertificatesRequest.cs index 9bb1d57..fa6990e 100644 --- a/src/Models/LetsEncryptServer/CertsFlow/Requests/RevokeCertificatesRequest.cs +++ b/src/Models/LetsEncryptServer/CertsFlow/Requests/RevokeCertificatesRequest.cs @@ -1,19 +1,14 @@ -using MaksIT.Core.Abstractions.Webapi; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.ComponentModel.DataAnnotations; +using MaksIT.Core.Abstractions.Webapi; -namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests { - public class RevokeCertificatesRequest : RequestModelBase { - - public required string [] Hostnames { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) { - if (Hostnames == null || Hostnames.Length == 0) - yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); - } +namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; + +public class RevokeCertificatesRequest : RequestModelBase { + public required string [] Hostnames { get; set; } + + public override IEnumerable Validate(ValidationContext validationContext) { + if (Hostnames == null || Hostnames.Length == 0) + yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) }); } } diff --git a/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs b/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs index 684861a..b472e54 100644 --- a/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs +++ b/src/Models/LetsEncryptServer/Identity/Login/LoginRequest.cs @@ -1,7 +1,7 @@ using MaksIT.Core.Abstractions.Webapi; -namespace Models.LetsEncryptServer.Identity.Login; +namespace MaksIT.Models.LetsEncryptServer.Identity.Login; public class LoginRequest : RequestModelBase { public required string Username { get; set; } diff --git a/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs b/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs index 33ce2a7..32c66f0 100644 --- a/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs +++ b/src/Models/LetsEncryptServer/Identity/Login/LoginResponse.cs @@ -1,10 +1,9 @@ using MaksIT.Core.Abstractions.Webapi; -namespace Models.LetsEncryptServer.Identity.Login; +namespace MaksIT.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; } diff --git a/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs b/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs index 93b15c1..f9a5455 100644 --- a/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs +++ b/src/Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs @@ -1,7 +1,7 @@ using MaksIT.Core.Abstractions.Webapi; -namespace Models.LetsEncryptServer.Identity.Login; +namespace MaksIT.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 index c8b79e0..1814dfa 100644 --- a/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs +++ b/src/Models/LetsEncryptServer/Identity/Logout/LogoutRequest.cs @@ -1,7 +1,7 @@ using MaksIT.Core.Abstractions.Webapi; -namespace Models.LetsEncryptServer.Identity.Logout; +namespace MaksIT.Models.LetsEncryptServer.Identity.Logout; public class LogoutRequest : RequestModelBase { public required string Token { get; set; } diff --git a/src/Models/LetsEncryptServer/Identity/User/PatchUserRequest.cs b/src/Models/LetsEncryptServer/Identity/User/PatchUserRequest.cs new file mode 100644 index 0000000..bf128ab --- /dev/null +++ b/src/Models/LetsEncryptServer/Identity/User/PatchUserRequest.cs @@ -0,0 +1,8 @@ +using MaksIT.Core.Abstractions.Webapi; + + +namespace MaksIT.Models.LetsEncryptServer.Identity.User; + +public class PatchUserRequest : PatchRequestModelBase { + public string? Password { get; set; } +} diff --git a/src/Models/LetsEncryptServer/Identity/User/UserResponse.cs b/src/Models/LetsEncryptServer/Identity/User/UserResponse.cs new file mode 100644 index 0000000..99cffd7 --- /dev/null +++ b/src/Models/LetsEncryptServer/Identity/User/UserResponse.cs @@ -0,0 +1,7 @@ +using MaksIT.Core.Abstractions.Webapi; + + +namespace MaksIT.Models.LetsEncryptServer.Identity.User; + +public class UserResponse : ResponseModelBase { +} diff --git a/src/Models/Models.csproj b/src/Models/MaksIT.Models.csproj similarity index 84% rename from src/Models/Models.csproj rename to src/Models/MaksIT.Models.csproj index 32b2a26..ff6c3e0 100644 --- a/src/Models/Models.csproj +++ b/src/Models/MaksIT.Models.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/ReverseProxy/ReverseProxy.csproj b/src/ReverseProxy/ReverseProxy.csproj index 36f9dcb..a9d42fe 100644 --- a/src/ReverseProxy/ReverseProxy.csproj +++ b/src/ReverseProxy/ReverseProxy.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 55f51a8..38d85f6 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -15,4 +15,4 @@ services: image: ${DOCKER_REGISTRY-}certs-ui-server build: context: . - dockerfile: LetsEncryptServer/Dockerfile + dockerfile: MaksIT.Webapi/Dockerfile diff --git a/src/helm/templates/NOTES.txt b/src/helm/templates/NOTES.txt index 739f4e2..a5978bd 100644 --- a/src/helm/templates/NOTES.txt +++ b/src/helm/templates/NOTES.txt @@ -19,14 +19,14 @@ This chart deploys the MaksIT CertsUI tool for automated Let's Encrypt HTTPS cer The server uses a ConfigMap (`appsettings.json`) for application settings. - **Persistence**: - PVCs are created for `/acme` and `/cache` directories. + PVCs are created for `/acme`, `/cache` and `/data` directories. ------------------------------------------------------------ ## Uninstall To remove all resources created by this chart: ``` -helm uninstall {{ .Release.Name }} +helm uninstall {{ .Release.Name }} -n {{ .Release.Name }} ``` ------------------------------------------------------------ diff --git a/src/helm/values.yaml b/src/helm/values.yaml index 9c0ca7b..35be399 100644 --- a/src/helm/values.yaml +++ b/src/helm/values.yaml @@ -38,17 +38,29 @@ components: storageClass: local-path size: 50Mi accessModes: [ReadWriteOnce] + - name: data + mountPath: /data + type: pvc + pvc: + create: true + storageClass: local-path + size: 50Mi + accessModes: [ReadWriteOnce] secretsFile: key: appsecrets.json mountPath: /secrets/appsecrets.json content: | { + "Auth": { + "Secret": "", + "Pepper": "" + }, "Agent": { - } + "AgentKey": "" + }, } keep: true forceUpdate: false - configMapFile: key: appsettings.json mountPath: /configMap/appsettings.json @@ -61,17 +73,26 @@ components: } }, "Configuration": { - "Production": "https://acme-v02.api.letsencrypt.org/directory", - "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", - - "CacheFolder": "/cache", - "AcmeFolder": "/acme", + "Auth": { + "Issuer": "", + "Audience": "", + "Expiration": 15, // Access token lifetime in minutes (default: 15 minutes) + "RefreshExpiration": 180, // Refresh token lifetime in days (default: 180 days) + }, "Agent": { "AgentHostname": "http://websrv0001.corp.maks-it.com", "AgentPort": 5000, "ServiceToReload": "haproxy" - } + }, + + "Production": "https://acme-v02.api.letsencrypt.org/directory", + "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", + "CacheFolder": "/cache", + "AcmeFolder": "/acme", + + "DataFolder": "/data", + "SettingsFile": "/data/settings.json", } } keep: true