(feature): jwt, password change, general codebase improvements

This commit is contained in:
Maksym Sadovnychyy 2025-11-11 18:39:47 +01:00
parent 712b880ab2
commit 7a745a30db
73 changed files with 1076 additions and 818 deletions

View File

@ -9,11 +9,11 @@
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Models\Models.csproj" />
<ProjectReference Include="..\Models\MaksIT.Models.csproj" />
</ItemGroup>
<ItemGroup>

View File

@ -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

View File

@ -8,13 +8,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.3" />
<PackageReference Include="MaksIT.Core" Version="1.5.4" />
<PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@ -13,20 +13,13 @@ namespace MaksIT.LetsEncrypt.Services;
public interface IJwsService {
void SetKeyId(string location);
JwsMessage Encode(JwsHeader protectedHeader);
JwsMessage Encode<TPayload>(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));

View File

@ -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<string> 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<T?> HandleUnhandledException<T>(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") {
List<string> messages = new() { defaultMessage };
_logger.LogError(ex, messages.FirstOrDefault());
ex.ExtractMessages().ForEach(m => messages.Add(m));
return Result<T?>.InternalServerError(defaultValue, [.. messages]);
_logger.LogError(ex, defaultMessage);
return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
}
}

View File

@ -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<IActionResult> Login([FromBody] LoginRequest requestData) {
var result = await _identityService.LoginAsync(requestData);
return result.ToActionResult();
}
[HttpPost("refresh")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest requestData) {
var result = await _identityService.RefreshTokenAsync(requestData);
return result.ToActionResult();
}
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest requestData) {
var result = await _identityService.Logout(requestData);
return result.ToActionResult();
}
#endregion
}

View File

@ -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<Result<Settings?>> LoadAsync();
Task<Result> SaveAsync(Settings settings);
}
public class SettingsService : ISettingsService, IDisposable {
private readonly ILogger<SettingsService> _logger;
private readonly string _settingsPath;
private readonly LockManager _lockManager;
public SettingsService(
ILogger<SettingsService> logger,
IOptions<Configuration> appSettings
) {
_logger = logger;
_settingsPath = appSettings.Value.SettingsFile;
_lockManager = new LockManager();
}
#region Internal I/O
private async Task<Result<Settings?>> LoadInternalAsync() {
try {
if (!File.Exists(_settingsPath))
return Result<Settings?>.Ok(new Settings());
var json = await File.ReadAllTextAsync(_settingsPath);
var settingsDto = json.ToObject<SettingsDto>();
if (settingsDto == null)
return Result<Settings?>.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<Settings?>.Ok(settings);
}
catch (Exception ex) {
var message = "Error loading settings file.";
_logger.LogError(ex, message);
return Result<Settings?>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
}
}
private async Task<Result> 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<Result<Settings?>> LoadAsync() {
return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync());
}
public async Task<Result> SaveAsync(Settings settings) {
return await _lockManager.ExecuteWithLockAsync(() => SaveInternalAsync(settings));
}
public void Dispose() {
_lockManager.Dispose();
}
}

View File

@ -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',

View File

@ -13,7 +13,6 @@ const axiosInstance = axios.create({
timeout: 10000, // Set a timeout if needed
})
let isRefreshing = false
let refreshPromise: Promise<unknown> | 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)
}
}
}

View File

@ -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<HTMLInputElement>) => {
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 (
<div className={'flex items-center justify-center min-h-screen bg-gray-100'}>
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'}>
{/* Logo */}
<div className={'flex justify-center'}>
<img src={'/logo.png'} alt={'Logo'} className={'h-12 w-auto'} />
</div>
const handleSubmit = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') handleLogin()
}
return (
<div className={'relative min-h-screen bg-gray-100 flex items-center justify-center'}>
{/* Top left logo and company name */}
<a
href={import.meta.env.VITE_COMPANY_URL}
target={'_blank'}
rel={'noopener noreferrer'}
className={'absolute top-6 left-8 flex items-center space-x-3 z-10'}
>
<img src={'/logo.png'} alt={'Logo'} className={'h-10 w-auto'} />
</a>
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'} onKeyDown={handleSubmit} tabIndex={0}>
{/* App logo and name above form */}
<div className={'flex justify-center items-center space-x-3 mb-2'}>
<img src={'/logo.png'} alt={'App Logo'} className={'h-12 w-auto'} />
<span className={'text-2xl font-bold text-gray-800 select-none'}>{import.meta.env.VITE_APP_TITLE}</span>
</div>
{/* Form */}
<div className={'space-y-4'}>
<div className={'space-y-4'}>
@ -66,7 +71,6 @@ const LoginScreen: FC = () => {
onChange={(e) => handleInputChange('username', e.target.value)}
errorText={errors.username}
/>
<TextBoxComponent
label={'Password'}
placeholder={'Password...'}
@ -76,46 +80,6 @@ const LoginScreen: FC = () => {
errorText={errors.password}
/>
</div>
{/* 2FA Options */}
<div className={'flex items-center gap-4'}>
<CheckBoxComponent
label={'Use 2FA'}
value={use2FA}
onChange={handleUse2FA}
/>
{use2FA && (
<CheckBoxComponent
label={'Use 2FA Recovery'}
value={use2FARecovery}
onChange={(e) => setUse2FARecovery(e.target.checked)}
/>
)}
</div>
{/* 2FA Inputs */}
{use2FA && (
<div className={'space-y-4'}>
{use2FARecovery ? (
<TextBoxComponent
label={'2FA Recovery Code'}
placeholder={'Recovery code...'}
value={formState.twoFactorRecoveryCode}
onChange={(e) => handleInputChange('twoFactorRecoveryCode', e.target.value)}
errorText={errors.twoFactorRecoveryCode}
/>
) : (
<TextBoxComponent
label={'2FA Code'}
placeholder={'Authentication code...'}
value={formState.twoFactorCode}
onChange={(e) => handleInputChange('twoFactorCode', e.target.value)}
errorText={errors.twoFactorCode}
/>
)}
</div>
)}
{/* Submit */}
<ButtonComponent
label={'Sign in'}

View File

@ -1,8 +0,0 @@
const EditIdentity = () => {
return <div>Edit Identity Form</div>
}
export {
EditIdentity
}

View File

@ -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<ChangePasswordProps> = (props) => {
const {
userId,
isOpen = false,
onClose
} = props
const {
formState,
errors,
formIsValid,
handleInputChange,
setInitialState
} = useFormState<PatchUserChangePasswordRequest>({
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<PatchUserChangePasswordRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId), data)
.then(response => {
if (!response) return
handleOnClose()
})
}
}
return <Offcanvas isOpen={isOpen}>
<FormContainer>
<FormHeader>Change password</FormHeader>
<FormContent>
<div className={'grid grid-cols-12 gap-4 w-full h-full content-start'}>
<TextBoxComponent
colspan={12}
type={'password'}
label={'Password'}
value={formState.password}
errorText={errors.password}
onChange={(e) => handleInputChange('password', e.target.value)}
/>
<TextBoxComponent
colspan={12}
type={'password'}
label={'Confirm password'}
value={formState.confirmPassword}
errorText={errors.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
/>
<ButtonComponent
colspan={6}
label={'Save'}
buttonHierarchy={'primary'}
onClick={handleSave}
/>
<ButtonComponent
colspan={6}
label={'Cancel'}
buttonHierarchy={'secondary'}
onClick={handleOnClose}
/>
</div>
</FormContent>
</FormContainer>
</Offcanvas>
}
export {
ChangePassword
}

View File

@ -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<EditUserProps> = (props) => {
const {
userId,
onSubmitted,
cancelEnabled = false,
onCancel
} = props
const [showChangePassword, setShowChangePassword] = useState(false)
return <>
<FormContainer>
<FormHeader>Edit user</FormHeader>
<FormContent>
<div className={'grid grid-cols-12 gap-4 w-full'}>
<ButtonComponent
colspan={3}
label={'Change password'}
buttonHierarchy={'primary'}
onClick={() => setShowChangePassword(true)}
/>
<span className={'col-span-9'}></span>
</div>
</FormContent>
</FormContainer>
<ChangePassword
userId={userId}
isOpen={showChangePassword}
onClose={() => setShowChangePassword(false)}
/>
</>
}
export {
EditUser
}

View File

@ -1,8 +1,11 @@
import { EditIdentity } from '../forms/EditIdentity'
import { useParams } from 'react-router-dom'
import { EditUser } from '../forms/Users/EditUser'
const UserPage = () => {
return <EditIdentity />
const { userId } = useParams<{ userId: string }>()
return userId ? <EditUser userId={userId} /> : <>User not found</>
}
export {

View File

@ -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);
}

View File

@ -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<Configuration> appSettings) {
protected readonly ILogger _logger = logger;
protected readonly Configuration _appSettings = appSettings.Value;
protected Result UnsupportedPatchOperationResponse() {
return Result.BadRequest("Unsupported operation");

View File

@ -0,0 +1,14 @@
using MaksIT.Results;
namespace MaksIT.Webapi.Authorization.Extensions;
public static class HttpContextExtension {
public static Result<JwtTokenData?> GetJwtTokenData(this HttpContext context) {
var jwtTokenData = context.Items[HttpContextValue.JwtTokenData] as JwtTokenData;
if (jwtTokenData == null)
return Result<JwtTokenData?>.Forbidden(null, "JWT token data not found in the request");
return Result<JwtTokenData?>.Ok(jwtTokenData);
}
}

View File

@ -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<JwtAuthorizationFilter> logger,
IOptions<Configuration> 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<Result<JwtTokenData?>> ValidateJwtTokenAsync(string? token) {
if (string.IsNullOrWhiteSpace(token))
return Result<JwtTokenData?>.Forbidden(null, "Token is missing");
if (!JwtGenerator.TryValidateToken(
_jwtSettingsConfiguration.Secret,
_jwtSettingsConfiguration.Issuer,
_jwtSettingsConfiguration.Audience,
token,
out var jwtTokenClaims,
out string? errorMessage
)) {
return Result<JwtTokenData?>.InternalServerError(null, errorMessage);
}
if (jwtTokenClaims == null ||
jwtTokenClaims.Username == null ||
jwtTokenClaims.Roles == null ||
jwtTokenClaims.IssuedAt == null ||
jwtTokenClaims.ExpiresAt == null
) {
return Result<JwtTokenData?>.Forbidden(null, "Invalid JWT token or claims");
}
var settingsResult = await _settingsService.LoadAsync();
if (!settingsResult.IsSuccess || settingsResult.Value == null) {
return Result<JwtTokenData?>.InternalServerError(null, "Failed to load settings");
}
var settings = settingsResult.Value;
var userResult = settings.GetUserByName(jwtTokenClaims.Username);
if (!userResult.IsSuccess || userResult.Value == null) {
return Result<JwtTokenData?>.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<JwtTokenData?>.Ok(jwtTokenData);
}
}

View File

@ -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) { }
}

View File

@ -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; }
}

View File

@ -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<Configuration> _appSettings;

View File

@ -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;

View File

@ -1,6 +1,6 @@
using MaksIT.LetsEncrypt;
namespace MaksIT.LetsEncryptServer {
namespace MaksIT.Webapi {
public class Agent {
public required string AgentHostname { get; set; }

View File

@ -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<IActionResult> GetAccounts() {
var result = await _accountService.GetAccountsAsync();

View File

@ -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;

View File

@ -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<IActionResult> DeleteCache() {
var result = await _cacheService.DeleteCacheAsync();
return result.ToActionResult();
}

View File

@ -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 {
/// <summary>
/// Certificates flow controller, used for granular testing purposes
/// </summary>
[ApiController]
[Route("api/certs")]
[ServiceFilter(typeof(JwtAuthorizationFilter))]
public class CertsFlowController : ControllerBase {
private readonly ICertsFlowService _certsFlowService;

View File

@ -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;
/// <summary>
/// Patch user data.
/// </summary>
/// <param name="id">Nullable Id as user can patch his own data</param>
/// <param name="requestData"></param>
/// <returns></returns>
[ServiceFilter(typeof(JwtAuthorizationFilter))]
[HttpPatch("user/{id:guid}")]
[ProducesResponseType(typeof(UserResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> Login([FromBody] LoginRequest requestData) {
var result = await _identityService.LoginAsync(requestData);
return result.ToActionResult();
}
[HttpPost("refresh")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest requestData) {
var result = await _identityService.RefreshTokenAsync(requestData);
return result.ToActionResult();
}
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest requestData) {
var result = await _identityService.Logout(requestData);
return result.ToActionResult();
}
#endregion
}

View File

@ -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")]

View File

@ -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"]
ENTRYPOINT ["dotnet", "MaksIT.Webapi.dll"]

View File

@ -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<Guid>(id) {

View File

@ -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<Settings?>.Ok(this);
}
public Result<User?> GetUserById(Guid id) {
var user = Users.FirstOrDefault(x => x.Id == id);
if (user == null)
return Result<User?>.NotFound(null, "User not found.");
return Result<User?>.Ok(user);
}
public Result<User?> GetUserByName(string name) {
var user = Users.FirstOrDefault(x => x.Name == name);
if (user == null)

View File

@ -2,7 +2,7 @@
using MaksIT.Core.Security;
using MaksIT.Results;
namespace MaksIT.LetsEncryptServer.Domain;
namespace MaksIT.Webapi.Domain;
public class User(
Guid id

View File

@ -1,6 +1,6 @@
using MaksIT.Core.Abstractions.Dto;
namespace MaksIT.LetsEncryptServer.Dto;
namespace MaksIT.Webapi.Dto;
public class JwtTokenDto : DtoDocumentBase<Guid> {
public required string Token { get; set; }

View File

@ -1,4 +1,4 @@
namespace MaksIT.LetsEncryptServer.Dto;
namespace MaksIT.Webapi.Dto;
public class SettingsDto {
public required bool Init { get; set; }

View File

@ -1,6 +1,6 @@
using MaksIT.Core.Abstractions.Dto;
namespace MaksIT.LetsEncryptServer.Dto;
namespace MaksIT.Webapi.Dto;
public class UserDto : DtoDocumentBase<Guid> {
public required string Name { get; set; } = string.Empty;

View File

@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LetsEncrypt\LetsEncrypt.csproj" />
<ProjectReference Include="..\Models\Models.csproj" />
<ProjectReference Include="..\Models\MaksIT.Models.csproj" />
</ItemGroup>
</Project>

View File

@ -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<Configuration>() ?? throw new Argumen
// Allow configurations to be available through IOptions<Configuration>
builder.Services.Configure<Configuration>(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<JwtAuthorizationFilter>();
#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<IAgentService, AgentService>();
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<IIdentityService, IdentityService>();
// Hosted services
#region Hosted services
builder.Services.AddHostedService<AutoRenewal>();
builder.Services.AddHostedService<Initialization>();
#endregion
var app = builder.Build();

View File

@ -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<Result<GetAccountResponse[]?>> GetAccountsAsync();
@ -17,27 +17,21 @@ public interface IAccountService {
Task<Result> DeleteAccountAsync(Guid accountId);
}
public class AccountService : ServiceBase, IAccountService {
private readonly ILogger<CacheService> _logger;
private readonly ICacheService _cacheService;
private readonly ICertsFlowService _certsFlowService;
public AccountService(
ILogger<CacheService> logger,
ICacheService cacheService,
ICertsFlowService certsFlowService
) {
_logger = logger;
_cacheService = cacheService;
_certsFlowService = certsFlowService;
}
public class AccountService(
ILogger<CacheService> logger,
IOptions<Configuration> appSettings,
ICacheService cacheService,
ICertsFlowService certsFlowService
) : ServiceBase(
logger,
appSettings
), IAccountService {
#region Accounts
public async Task<Result<GetAccountResponse[]?>> GetAccountsAsync() {
var accountsFromCacheResult = await _cacheService.LoadAccountsFromCacheAsync();
var accountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync();
if (!accountsFromCacheResult.IsSuccess || accountsFromCacheResult.Value == null) {
return accountsFromCacheResult
.ToResultOfType<GetAccountResponse[]?>(_ => null);
@ -51,7 +45,7 @@ public class AccountService : ServiceBase, IAccountService {
}
public async Task<Result<GetAccountResponse?>> GetAccountAsync(Guid accountId) {
var loadFromCacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId);
var loadFromCacheResult = await cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadFromCacheResult.IsSuccess || loadFromCacheResult.Value == null) {
return loadFromCacheResult.ToResultOfType<GetAccountResponse?>(_ => null);
}
@ -63,7 +57,7 @@ public class AccountService : ServiceBase, IAccountService {
public async Task<Result<GetAccountResponse?>> 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<GetAccountResponse?>(_ => null);
}
@ -88,7 +82,7 @@ public class AccountService : ServiceBase, IAccountService {
}
public async Task<Result<GetAccountResponse?>> 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<GetAccountResponse?>(_ => 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<GetAccountResponse?>(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<GetAccountResponse?>(_ => 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

View File

@ -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<Result<HelloWorldResponse?>> GetHelloWorld();
@ -14,21 +15,14 @@ namespace MaksIT.LetsEncryptServer.Services {
Task<Result> ReloadService(string serviceName);
}
public class AgentService : IAgentService {
private readonly Configuration _appSettings;
private readonly ILogger<AgentService> _logger;
private readonly HttpClient _httpClient;
public AgentService(
IOptions<Configuration> appSettings,
ILogger<AgentService> logger,
HttpClient httpClient
) {
_appSettings = appSettings.Value;
_logger = logger;
_httpClient = httpClient;
}
public class AgentService(
IOptions<Configuration> appSettings,
ILogger<AgentService> logger,
HttpClient httpClient
) : ServiceBase(
logger,
appSettings
), IAgentService {
public async Task<Result<HelloWorldResponse?>> 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<HelloWorldResponse?>.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<HelloWorldResponse?>.InternalServerError(null, $"Request to {endpoint} failed with status code: {response.StatusCode}");
}
@ -60,7 +53,7 @@ namespace MaksIT.LetsEncryptServer.Services {
catch (Exception ex) {
List<string> 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<string> messages = new() { "Something went wrong" };
_logger.LogError(ex, messages.FirstOrDefault());
logger.LogError(ex, messages.FirstOrDefault());
messages.Add(ex.Message);

View File

@ -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<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync();
Task<Result<RegistrationCache?>> LoadAccountFromCacheAsync(Guid accountId);
Task<Result> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
Task<Result> DeleteFromCacheAsync(Guid accountId);
Task<Result<byte[]>> DownloadCacheZipAsync();
Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId);
Task<Result> UploadCacheZipAsync(byte[] zipBytes);
Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes);
Result DeleteCacheAsync();
Task<Result> DeleteCacheAsync();
Task<Result> DeleteAccountCacheAsync(Guid accountId);
}
public class CacheService : ICacheService, IDisposable {
private readonly ILogger<CacheService> _logger;
private readonly string _cacheDirectory;
private readonly LockManager _lockManager;
public class CacheService(
ILogger<CacheService> logger,
IOptions<Configuration> 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<CacheService> logger,
IOptions<Configuration> appsettings
) {
_logger = logger;
_cacheDirectory = appsettings.Value.CacheFolder;
_lockManager = new LockManager();
}
/// <summary>
/// Generates the cache file path for the given account ID.
/// </summary>
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<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() {
return await _lockManager.ExecuteWithLockAsync(async () => {
var accountIds = GetCachedAccounts();
var cacheLoadTasks = accountIds.Select(accountId => LoadFromCacheInternalAsync(accountId)).ToList();
var caches = new List<RegistrationCache>();
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<RegistrationCache>();
if (cache != null)
caches.Add(cache);
}
return Result<RegistrationCache[]?>.Ok(caches.ToArray());
});
}
private async Task<Result<RegistrationCache?>> 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<RegistrationCache?>.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<RegistrationCache?>.InternalServerError(null, message);
}
var cache = json.ToObject<RegistrationCache>();
return Result<RegistrationCache?>.Ok(cache);
public async Task<Result<RegistrationCache?>> 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<RegistrationCache?>.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<RegistrationCache?>.InternalServerError(null, message);
}
var cache = json.ToObject<RegistrationCache>();
return Result<RegistrationCache?>.Ok(cache);
});
}
private async Task<Result> 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<Result> 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<Result<byte[]>> DownloadCacheZipAsync() {
try {
if (!Directory.Exists(_cacheDirectory)) {
var message = "Cache directory not found.";
_logger.LogWarning(message);
logger.LogWarning(message);
return Result<byte[]>.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<byte[]>.Ok(zipBytes);
}
catch (Exception ex) {
var message = "Error creating or reading cache zip file.";
_logger.LogError(ex, message);
logger.LogError(ex, message);
return Result<byte[]>.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<byte[]?>.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<byte[]?>.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<byte[]?>.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<Result> 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<Result> 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
/// <summary>
/// Generates the cache file path for the given account ID.
/// </summary>
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<Result<RegistrationCache?>> LoadAccountFromCacheAsync(Guid accountId) {
return await _lockManager.ExecuteWithLockAsync(() => LoadFromCacheInternalAsync(accountId));
}
public async Task<Result> SaveToCacheAsync(Guid accountId, RegistrationCache cache) {
return await _lockManager.ExecuteWithLockAsync(() => SaveToCacheInternalAsync(accountId, cache));
}
public async Task<Result> DeleteFromCacheAsync(Guid accountId) {
return await _lockManager.ExecuteWithLockAsync(() => DeleteFromCacheInternal(accountId));
}
public void Dispose() {
_lockManager.Dispose();
}

View File

@ -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<string?> GetTermsOfService(Guid sessionId);
@ -22,77 +23,62 @@ public interface ICertsFlowService {
Result<string?> AcmeChallenge(string fileName);
}
public class CertsFlowService : ICertsFlowService {
private readonly Configuration _appSettings;
private readonly ILogger<CertsFlowService> _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<Configuration> appSettings,
ILogger<CertsFlowService> logger,
HttpClient httpClient,
ILetsEncryptService letsEncryptService,
ICacheService cacheService,
IAgentService agentService
) : ServiceBase(
logger,
appSettings
), ICertsFlowService {
public CertsFlowService(
IOptions<Configuration> appSettings,
ILogger<CertsFlowService> 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<string?> 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<string?>.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<string?>.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<string?>.InternalServerError(null, $"Failed to download, cache, or convert Terms of Service PDF: {ex.Message}");
}
}
public async Task<Result> CompleteChallengesAsync(Guid sessionId) {
return await _letsEncryptService.CompleteChallenges(sessionId);
return await letsEncryptService.CompleteChallenges(sessionId);
}
public async Task<Result<Guid?>> 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<Guid?>(default);
return Result<Guid?>.Ok(sessionId);
@ -100,13 +86,11 @@ public class CertsFlowService : ICertsFlowService {
public async Task<Result<Guid?>> 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<Guid?>(default);
return Result<Guid?>.Ok(accountId.Value);
}
public async Task<Result<List<string>?>> 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<List<string>?>(_ => null);
var challenges = new List<string>();
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<List<string>?>.Ok(challenges);
}
public async Task<Result> 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<Result> GetOrderAsync(Guid sessionId, string[] hostnames) {
return await _letsEncryptService.GetOrder(sessionId, hostnames);
return await letsEncryptService.GetOrder(sessionId, hostnames);
}
public async Task<Result<Dictionary<string, string>?>> 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<Dictionary<string, string>?>(_ => null);
var cache = cacheResult.Value;
var results = cache.GetCertsPemPerHostname();
if (cache.IsDisabled)
return Result<Dictionary<string, string>?>.BadRequest(null, $"Account {accountId} is disabled");
if (cache.IsStaging)
return Result<Dictionary<string, string>?>.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<Dictionary<string, string>?>(default);
var reloadResult = await _agentService.ReloadService(_appSettings.Agent.ServiceToReload);
var reloadResult = await agentService.ReloadService(_appSettings.Agent.ServiceToReload);
if (!reloadResult.IsSuccess)
return reloadResult.ToResultOfType<Dictionary<string, string>?>(default);
return Result<Dictionary<string, string>?>.Ok(results);
}
public async Task<Result> 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<Guid?>(_ => null);
if (accountId == null)
accountId = initResult.Value;
var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType);
if (!challengesResult.IsSuccess)
return challengesResult.ToResultOfType<Guid?>(_ => null);
if (challengesResult.Value?.Count > 0) {
var challengeResult = await CompleteChallengesAsync(sessionId);
if (!challengeResult.IsSuccess)
return challengeResult.ToResultOfType<Guid?>(default);
}
var getOrderResult = await GetOrderAsync(sessionId, hostnames);
if (!getOrderResult.IsSuccess)
return getOrderResult.ToResultOfType<Guid?>(default);
var certsResult = await GetCertificatesAsync(sessionId, hostnames);
if (!certsResult.IsSuccess)
return certsResult.ToResultOfType<Guid?>(default);
if (!isStaging) {
var applyCertsResult = await ApplyCertificatesAsync(accountId.Value);
if (!applyCertsResult.IsSuccess)
return applyCertsResult.ToResultOfType<Guid?>(_ => null);
}
return Result<Guid?>.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<string?> AcmeChallenge(string fileName) {
DeleteExporedChallenges();
var challengePath = Path.Combine(_acmePath, fileName);
if (!File.Exists(challengePath))
return Result<string?>.NotFound(null);
var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName));
return Result<string?>.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");
}
}
}

View File

@ -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<Result<UserResponse?>> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData);
#endregion
#region Login/Refresh/Logout
Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData);
Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData);
@ -19,12 +27,53 @@ public interface IIdentityService {
}
public class IdentityService(
IOptions<Configuration> appsettings,
ILogger<IdentityService> logger,
IOptions<Configuration> appSettings,
ISettingsService settingsService
) : IIdentityService {
) : ServiceBase(logger, appSettings), IIdentityService {
#region Patch
public async Task<Result<UserResponse?>> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData) {
var loadSettingsResult = await settingsService.LoadAsync();
if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) {
return loadSettingsResult.ToResultOfType<UserResponse?>(_ => null);
}
var settings = loadSettingsResult.Value;
var userResult = settings.GetUserById(id);
if (!userResult.IsSuccess || userResult.Value == null)
return userResult.ToResultOfType<UserResponse?>(_ => 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<UserResponse?>(nameof(requestData.Password));
user.SetPassword(requestData.Password, _appSettings.Auth.Pepper);
break;
default:
return UnsupportedPatchOperationResponse<UserResponse?>();
}
}
#endregion
settings.UpsertUser(user);
var saveSettingsResult = await settingsService.SaveAsync(settings);
if (!saveSettingsResult.IsSuccess)
return saveSettingsResult.ToResultOfType<UserResponse?>(default);
return Result<UserResponse?>.Ok(new UserResponse {
});
}
#endregion
private readonly Configuration _appSettings = appsettings.Value;
#region Login/Refresh/Logout
public async Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData) {
@ -90,59 +139,59 @@ public class IdentityService(
public async Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData) {
var loadSettingsResult = await settingsService.LoadAsync();
if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null)
return loadSettingsResult.ToResultOfType<LoginResponse?>(_ => null);
return loadSettingsResult.ToResultOfType<LoginResponse?>(_ => null);
var settings = loadSettingsResult.Value;
var userResult = settings.GetByRefreshToken(requestData.RefreshToken);
if (!userResult.IsSuccess || userResult.Value == null)
return Result<LoginResponse?>.Unauthorized(null, "Invalid refresh token.");
return Result<LoginResponse?>.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<LoginResponse?>.Unauthorized(null, "Invalid refresh token.");
return Result<LoginResponse?>.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<LoginResponse?>(default);
var saveResult = await settingsService.SaveAsync(settings);
if (!saveResult.IsSuccess)
return saveResult.ToResultOfType<LoginResponse?>(default);
return Result<LoginResponse?>.Ok(new LoginResponse {
TokenType = tokenDomain.TokenType,
Token = tokenDomain.Token,
ExpiresAt = tokenDomain.ExpiresAt,
RefreshToken = tokenDomain.RefreshToken,
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
});
return Result<LoginResponse?>.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<LoginResponse?>.Unauthorized(null, "Refresh token has expired.");
user.RemoveJwtToken(tokenDomain.Id);
return Result<LoginResponse?>.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<LoginResponse?>.InternalServerError(null, errorMessage);
return Result<LoginResponse?>.InternalServerError(null, errorMessage);
var (token, claims) = tokenData.Value;
if (claims.IssuedAt == null || claims.ExpiresAt == null)
return Result<LoginResponse?>.InternalServerError(null, "Token claims are missing required fields.");
return Result<LoginResponse?>.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<LoginResponse?>(default);
return writeResult.ToResultOfType<LoginResponse?>(default);
return Result<LoginResponse?>.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
});
}

View File

@ -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<Result<Settings?>> LoadAsync();
Task<Result> SaveAsync(Settings settings);
}
public class SettingsService(
ILogger<SettingsService> logger,
IOptions<Configuration> 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<Result<Settings?>> LoadInternalAsync() {
try {
if (!File.Exists(_settingsPath))
return Result<Settings?>.Ok(new Settings());
var json = await File.ReadAllTextAsync(_settingsPath);
var settingsDto = json.ToObject<SettingsDto>();
if (settingsDto == null)
return Result<Settings?>.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<Settings?>.Ok(settings);
}
catch (Exception ex) {
var message = "Error loading settings file.";
_logger.LogError(ex, message);
return Result<Settings?>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
}
}
private async Task<Result> 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<Result<Settings?>> LoadAsync() {
return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync());
}
public async Task<Result> SaveAsync(Settings settings) {
return await _lockManager.ExecuteWithLockAsync(() => SaveInternalAsync(settings));
}
public void Dispose() {
_lockManager.Dispose();
}
}

View File

@ -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"
}
}

View File

@ -1,7 +1,8 @@
namespace MaksIT.Models.Agent.Requests {
public class CertsUploadRequest {
using MaksIT.Core.Abstractions.Webapi;
public Dictionary<string, string> Certs { get; set; }
}
namespace MaksIT.Models.Agent.Requests;
public class CertsUploadRequest : RequestModelBase {
public Dictionary<string, string> Certs { get; set; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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<string>? Contacts { get; set; }
public List<PatchHostnameRequest>? Hostnames { get; set; }
}

View File

@ -2,9 +2,9 @@
namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public class PatchHostnameRequest : PatchRequestModelBase {
public string? Hostname { get; set; }
public bool? IsDisabled { get; set; }
}

View File

@ -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<ValidationResult> 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)]);
}
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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<ValidationResult> 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<ValidationResult> Validate(ValidationContext validationContext) {
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
}
}

View File

@ -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<ValidationResult> 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<ValidationResult> Validate(ValidationContext validationContext) {
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
}
}

View File

@ -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<ValidationResult> 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<ValidationResult> 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) });
}
}

View File

@ -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<ValidationResult> 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<ValidationResult> 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) });
}
}

View File

@ -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; }
namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
}
public class RevokeCertificatesRequest : RequestModelBase {
public required string [] Hostnames { get; set; }
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
}
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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

View File

@ -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; }

View File

@ -0,0 +1,8 @@
using MaksIT.Core.Abstractions.Webapi;
namespace MaksIT.Models.LetsEncryptServer.Identity.User;
public class PatchUserRequest : PatchRequestModelBase {
public string? Password { get; set; }
}

View File

@ -0,0 +1,7 @@
using MaksIT.Core.Abstractions.Webapi;
namespace MaksIT.Models.LetsEncryptServer.Identity.User;
public class UserResponse : ResponseModelBase {
}

View File

@ -11,7 +11,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.3" />
<PackageReference Include="MaksIT.Core" Version="1.5.4" />
</ItemGroup>
</Project>

View File

@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>

View File

@ -15,4 +15,4 @@ services:
image: ${DOCKER_REGISTRY-}certs-ui-server
build:
context: .
dockerfile: LetsEncryptServer/Dockerfile
dockerfile: MaksIT.Webapi/Dockerfile

View File

@ -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 }}
```
------------------------------------------------------------

View File

@ -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