mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-05-16 04:48:12 +02:00
(refactor): LetsEncrypt cache entities (primary ctor, cert loader, null guard)
This commit is contained in:
parent
f70742cf18
commit
685a174806
11
CHANGELOG.md
11
CHANGELOG.md
@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.3.5] - 2026-04-12
|
||||
|
||||
### Changed
|
||||
|
||||
- `CachedHostname` now uses a C# 12 primary constructor (same public construction as before).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `RegistrationCache` loads cached PEM certificates via `X509CertificateLoader.LoadCertificate` and disposes them with `using` where certificates are parsed for expiry and host listing.
|
||||
- `RegistrationCache.TryGetCachedCertificate` returns `false` when the cached entry has no private key blob, avoiding a null argument when importing key material.
|
||||
|
||||
## [3.3.4] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
namespace MaksIT.LetsEncrypt.Entities;
|
||||
|
||||
public class CachedHostname {
|
||||
public string Hostname { get; set; }
|
||||
public DateTime Expires { get; set; }
|
||||
public bool IsUpcomingExpire { get; set; }
|
||||
|
||||
public bool IsDisabled { get; set; }
|
||||
|
||||
public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire, bool isDisabled) {
|
||||
Hostname = hostname;
|
||||
Expires = expires;
|
||||
IsUpcomingExpire = isUpcomingExpire;
|
||||
IsDisabled = isDisabled;
|
||||
}
|
||||
public class CachedHostname(
|
||||
string hostname,
|
||||
DateTime expires,
|
||||
bool isUpcomingExpire,
|
||||
bool isDisabled
|
||||
) {
|
||||
public string Hostname { get; set; } = hostname;
|
||||
public DateTime Expires { get; set; } = expires;
|
||||
public bool IsUpcomingExpire { get; set; } = isUpcomingExpire;
|
||||
public bool IsDisabled { get; set; } = isDisabled;
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ public class RegistrationCache {
|
||||
var (subject, cachedChert) = result;
|
||||
|
||||
if (cachedChert.Cert != null && !cachedChert.IsDisabled) {
|
||||
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
||||
using var cert = X509CertificateLoader.LoadCertificate(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
||||
|
||||
// if it is about to expire, we need to refresh
|
||||
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < days)
|
||||
@ -63,7 +63,7 @@ public class RegistrationCache {
|
||||
var (subject, cachedChert) = result;
|
||||
|
||||
if (cachedChert.Cert != null) {
|
||||
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
||||
using var cert = X509CertificateLoader.LoadCertificate(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
||||
|
||||
hosts.Add(new CachedHostname(
|
||||
subject,
|
||||
@ -93,11 +93,14 @@ public class RegistrationCache {
|
||||
return false;
|
||||
}
|
||||
|
||||
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cache.Cert));
|
||||
using var cert = X509CertificateLoader.LoadCertificate(Encoding.ASCII.GetBytes(cache.Cert));
|
||||
|
||||
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30)
|
||||
return false;
|
||||
|
||||
if (cache.Private is null)
|
||||
return false;
|
||||
|
||||
var rsa = new RSACryptoServiceProvider(4096);
|
||||
rsa.ImportCspBlob(cache.Private);
|
||||
|
||||
@ -106,6 +109,7 @@ public class RegistrationCache {
|
||||
Private = rsa.ExportCspBlob(true),
|
||||
PrivatePem = rsa.ExportRSAPrivateKeyPem()
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -128,13 +132,15 @@ public class RegistrationCache {
|
||||
if (CachedCerts == null)
|
||||
return result;
|
||||
|
||||
foreach (var kvp in CachedCerts) {
|
||||
foreach (var kvp in CachedCerts)
|
||||
{
|
||||
var hostname = kvp.Key;
|
||||
var cert = kvp.Value;
|
||||
if (!string.IsNullOrEmpty(cert.Cert) && !string.IsNullOrEmpty(cert.PrivatePem)) {
|
||||
result[hostname] = $"{cert.Cert}\n{cert.PrivatePem}";
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using MaksIT.Core.Security.JWK;
|
||||
using MaksIT.LetsEncrypt.Models.Responses;
|
||||
using MaksIT.LetsEncrypt.Services;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
|
||||
|
||||
@ -9,4 +9,7 @@ public class LoginResponse : ResponseModelBase {
|
||||
public required DateTime ExpiresAt { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
public required DateTime RefreshTokenExpiresAt { get; set; }
|
||||
|
||||
/// <summary>Actual login username; use this for display so it is not replaced by a display name (e.g. "Organization Admin") from the JWT name claim.</summary>
|
||||
public string? Username { get; set; }
|
||||
}
|
||||
@ -1,19 +1,51 @@
|
||||
import { LoginResponse } from '../models/identity/login/LoginResponse'
|
||||
|
||||
const readIdentity = () => {
|
||||
const json = localStorage.getItem('identity')
|
||||
/**
|
||||
* Maps API/localStorage payloads to a single camelCase LoginResponse so stored JSON matches across apps.
|
||||
*/
|
||||
const normalizeLoginResponse = (raw: unknown): LoginResponse | undefined => {
|
||||
if (raw == null || typeof raw !== 'object') return undefined
|
||||
const r = raw as Record<string, unknown>
|
||||
const str = (camel: keyof LoginResponse, pascal: string): string => {
|
||||
const v = r[camel as string] ?? r[pascal]
|
||||
if (v == null) return ''
|
||||
return typeof v === 'string' ? v : String(v)
|
||||
}
|
||||
const token = str('token', 'Token')
|
||||
const refreshToken = str('refreshToken', 'RefreshToken')
|
||||
if (!token || !refreshToken) return undefined
|
||||
|
||||
if (!json) return undefined
|
||||
const out: LoginResponse = {
|
||||
tokenType: str('tokenType', 'TokenType') || 'Bearer',
|
||||
token,
|
||||
expiresAt: str('expiresAt', 'ExpiresAt'),
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt: str('refreshTokenExpiresAt', 'RefreshTokenExpiresAt'),
|
||||
}
|
||||
const u = r.username ?? r.Username
|
||||
if (typeof u === 'string' && u.length > 0)
|
||||
out.username = u
|
||||
|
||||
return JSON.parse(json) as LoginResponse
|
||||
return out
|
||||
}
|
||||
|
||||
const writeIdentity = (identity: LoginResponse) => {
|
||||
localStorage.setItem('identity', JSON.stringify(identity))
|
||||
const readIdentity = (): LoginResponse | undefined => {
|
||||
const json = localStorage.getItem('identity')
|
||||
if (!json) return undefined
|
||||
try {
|
||||
return normalizeLoginResponse(JSON.parse(json) as unknown)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const writeIdentity = (identity: LoginResponse | unknown) => {
|
||||
const n = normalizeLoginResponse(identity)
|
||||
if (n) localStorage.setItem('identity', JSON.stringify(n))
|
||||
}
|
||||
|
||||
const removeIdentity = () => {
|
||||
localStorage.removeItem('identity')
|
||||
}
|
||||
|
||||
export { readIdentity, writeIdentity, removeIdentity }
|
||||
export { readIdentity, writeIdentity, removeIdentity, normalizeLoginResponse }
|
||||
|
||||
@ -4,4 +4,6 @@ export interface LoginResponse {
|
||||
expiresAt: string
|
||||
refreshToken: string
|
||||
refreshTokenExpiresAt: string
|
||||
/** Actual login username from the server; use for display so it is not replaced by a display name (e.g. "Organization Admin") from the JWT. */
|
||||
username?: string
|
||||
}
|
||||
@ -7,25 +7,14 @@ import { LoginRequest } from '../../models/identity/login/LoginRequest'
|
||||
import { ApiRoutes, GetApiRoute } from '../../AppMap'
|
||||
import { LogoutRequest } from '../../models/identity/logout/LogoutRequest'
|
||||
import { LogoutResponse } from '../../models/identity/logout/LogoutResponse'
|
||||
import { readIdentity, removeIdentity, writeIdentity } from '../../localStorage/identity'
|
||||
import { readIdentity, removeIdentity, writeIdentity, normalizeLoginResponse } from '../../localStorage/identity'
|
||||
import { RefreshTokenRequest } from '../../models/identity/login/RefreshTokenRequest'
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { Claims } from '../../models/identity/Claims'
|
||||
import { enumToArr, parseAclEntries } from '../../functions'
|
||||
import { Role } from '../../models/identity/Role'
|
||||
import { AclEntry } from '../../functions/acl/parseAclEntry'
|
||||
|
||||
interface IdentityRole {
|
||||
value: string | number,
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Identity extends LoginResponse {
|
||||
userId?: string,
|
||||
username?: string
|
||||
roles?: IdentityRole []
|
||||
isGlobalAdmin: boolean
|
||||
acls?: AclEntry []
|
||||
}
|
||||
|
||||
interface IdentityState {
|
||||
@ -36,24 +25,6 @@ interface IdentityState {
|
||||
hydrated: boolean
|
||||
}
|
||||
|
||||
/** API JSON may be camelCase or PascalCase; normalize so Authorization and axios see stable keys. */
|
||||
const normalizeLoginResponse = (raw: LoginResponse | undefined): LoginResponse | undefined => {
|
||||
if (!raw) return undefined
|
||||
const r = raw as Record<string, unknown>
|
||||
const str = (camel: keyof LoginResponse, pascal: string) => {
|
||||
const v = r[camel] ?? r[pascal]
|
||||
if (v == null) return ''
|
||||
return typeof v === 'string' ? v : String(v)
|
||||
}
|
||||
return {
|
||||
tokenType: str('tokenType', 'TokenType'),
|
||||
token: str('token', 'Token'),
|
||||
expiresAt: str('expiresAt', 'ExpiresAt'),
|
||||
refreshToken: str('refreshToken', 'RefreshToken'),
|
||||
refreshTokenExpiresAt: str('refreshTokenExpiresAt', 'RefreshTokenExpiresAt'),
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: IdentityState = {
|
||||
identity: null,
|
||||
showUserOffcanvas: false,
|
||||
@ -104,62 +75,26 @@ const refreshJwt = createAsyncThunk(
|
||||
)
|
||||
|
||||
const enrichStateWithJwtContent = (token: string, identity: Identity) => {
|
||||
const jwtContent = jwtDecode(token) as Record<string, unknown>
|
||||
let jwtContent: Record<string, unknown>
|
||||
try {
|
||||
jwtContent = jwtDecode(token) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (jwtContent) {
|
||||
if (jwtContent[Claims.NameIdentifier])
|
||||
identity.userId = jwtContent[Claims.NameIdentifier] as string
|
||||
|
||||
// Keep the original username: prefer username from login/refresh response, then JWT claims (do not replace with a display name like "Organization Admin" from the name claim)
|
||||
if (identity.username == null || identity.username?.trim() === '') {
|
||||
const nameClaim = jwtContent[Claims.Name] as string | undefined
|
||||
const usernameClaim = (jwtContent['username'] ?? jwtContent['preferred_username']) as string | undefined
|
||||
identity.username = (usernameClaim?.trim()) ? usernameClaim : nameClaim
|
||||
}
|
||||
|
||||
if (jwtContent[Claims.Role]) {
|
||||
|
||||
const appKnownRoles = enumToArr(Role)?.map(item => {
|
||||
return {
|
||||
value: item.value,
|
||||
label: item.displayValue
|
||||
}
|
||||
})
|
||||
|
||||
const jwtRoles: string[] = Array.isArray(jwtContent[Claims.Role])
|
||||
? jwtContent[Claims.Role] as string[]
|
||||
: jwtContent[Claims.Role]
|
||||
? [jwtContent[Claims.Role] as string]
|
||||
: []
|
||||
|
||||
const identityRoles: IdentityRole [] = []
|
||||
jwtRoles.forEach(identityRole => {
|
||||
const foundRole = appKnownRoles.find(role => role.label === identityRole)
|
||||
if (foundRole) {
|
||||
identityRoles.push(foundRole)
|
||||
}
|
||||
})
|
||||
|
||||
identity.roles = identityRoles
|
||||
}
|
||||
|
||||
if (jwtContent[Claims.AclEntry]) {
|
||||
const jwtAcls: string[] = Array.isArray(jwtContent[Claims.AclEntry])
|
||||
? jwtContent[Claims.AclEntry] as string[]
|
||||
: jwtContent[Claims.AclEntry]
|
||||
? [jwtContent[Claims.AclEntry] as string]
|
||||
: []
|
||||
|
||||
if (jwtAcls?.includes('global:admin') ?? false) {
|
||||
jwtAcls.splice(jwtAcls.indexOf('global:admin'), 1)
|
||||
identity.isGlobalAdmin = true
|
||||
}
|
||||
else {
|
||||
identity.isGlobalAdmin = false
|
||||
}
|
||||
|
||||
identity.acls = parseAclEntries(jwtAcls)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Enriched identity:', identity)
|
||||
}
|
||||
|
||||
const identitySlice = createSlice({
|
||||
@ -167,13 +102,10 @@ const identitySlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
setIdentityFromLocalStorage: (state) => {
|
||||
const raw = readIdentity()
|
||||
const identity = normalizeLoginResponse(raw)
|
||||
const identity = readIdentity()
|
||||
|
||||
if (identity?.token && identity.refreshTokenExpiresAt) {
|
||||
writeIdentity(identity)
|
||||
if (identity) {
|
||||
state.identity = {
|
||||
isGlobalAdmin: false,
|
||||
...identity
|
||||
}
|
||||
enrichStateWithJwtContent(identity.token, state.identity)
|
||||
@ -204,15 +136,14 @@ const identitySlice = createSlice({
|
||||
})
|
||||
.addCase(login.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
|
||||
state.status = 'idle'
|
||||
const payload = normalizeLoginResponse(action.payload)
|
||||
if (payload?.token && payload.refreshTokenExpiresAt) {
|
||||
const normalized = normalizeLoginResponse(action.payload)
|
||||
if (normalized) {
|
||||
state.identity = {
|
||||
isGlobalAdmin: false,
|
||||
...payload
|
||||
...normalized
|
||||
}
|
||||
writeIdentity(payload)
|
||||
writeIdentity(normalized)
|
||||
|
||||
enrichStateWithJwtContent(payload.token, state.identity)
|
||||
enrichStateWithJwtContent(normalized.token, state.identity)
|
||||
}
|
||||
})
|
||||
.addCase(login.rejected, (state) => {
|
||||
@ -241,17 +172,17 @@ const identitySlice = createSlice({
|
||||
.addCase(refreshJwt.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
|
||||
state.status = 'idle'
|
||||
|
||||
const payload = normalizeLoginResponse(action.payload)
|
||||
if (payload?.token && payload.refreshTokenExpiresAt) {
|
||||
const normalized = normalizeLoginResponse(action.payload)
|
||||
if (normalized) {
|
||||
state.identity = {
|
||||
isGlobalAdmin: false,
|
||||
...payload
|
||||
...normalized
|
||||
}
|
||||
writeIdentity(payload)
|
||||
writeIdentity(normalized)
|
||||
|
||||
enrichStateWithJwtContent(payload.token, state.identity)
|
||||
enrichStateWithJwtContent(normalized.token, state.identity)
|
||||
}
|
||||
else {
|
||||
// Refresh API returned error (e.g. 401 Invalid refresh token); treat as logged out
|
||||
state.identity = null
|
||||
state.showUserOffcanvas = false
|
||||
removeIdentity()
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>3.3.4</Version>
|
||||
<Version>3.3.5</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@ -130,7 +130,8 @@ public class IdentityService(
|
||||
Token = tokenDomain.Token,
|
||||
ExpiresAt = claims.ExpiresAt.Value,
|
||||
RefreshToken = tokenDomain.RefreshToken,
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt,
|
||||
Username = user.Name
|
||||
};
|
||||
|
||||
return Result<LoginResponse?>.Ok(response);
|
||||
@ -166,7 +167,8 @@ public class IdentityService(
|
||||
Token = tokenDomain.Token,
|
||||
ExpiresAt = tokenDomain.ExpiresAt,
|
||||
RefreshToken = tokenDomain.RefreshToken,
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt,
|
||||
Username = user.Name
|
||||
});
|
||||
}
|
||||
|
||||
@ -212,7 +214,8 @@ public class IdentityService(
|
||||
Token = tokenDomain.Token,
|
||||
ExpiresAt = claims.ExpiresAt.Value,
|
||||
RefreshToken = tokenDomain.RefreshToken,
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt,
|
||||
Username = user.Name
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -6,8 +6,7 @@
|
||||
<DockerPublishLocally>False</DockerPublishLocally>
|
||||
<ProjectGuid>0233e43f-435d-4309-b20c-ecd4bfbd2e63</ProjectGuid>
|
||||
<DockerLaunchAction>LaunchBrowser</DockerLaunchAction>
|
||||
<!-- reverseproxy is the only service with host port mappings; server/client are internal-only. Swagger is served via YARP at /swagger. -->
|
||||
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}/swagger</DockerServiceUrl>
|
||||
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}</DockerServiceUrl>
|
||||
<DockerServiceName>reverseproxy</DockerServiceName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user