mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-06-10 00:28:11 +02:00
357 lines
16 KiB
C#
357 lines
16 KiB
C#
using MaksIT.Results;
|
|
using MaksIT.Core.Security;
|
|
using MaksIT.Core.Security.JWT;
|
|
using MaksIT.CertsUI.Engine.Domain.Identity;
|
|
using Microsoft.Extensions.Logging;
|
|
using MaksIT.CertsUI.Engine.Persistence.Services;
|
|
|
|
|
|
namespace MaksIT.CertsUI.Engine.DomainServices;
|
|
|
|
public interface IIdentityDomainService {
|
|
|
|
#region Read
|
|
Result<User?> ReadUserById(Guid userId);
|
|
Result<User?> ReadUserByUsername(string usenrame);
|
|
Result<UserAuthorization?> ReadUserAuthorization(Guid userId);
|
|
#endregion
|
|
|
|
#region First boot Initialize
|
|
Task<Result<User?>> InitializeAdminAsync();
|
|
#endregion
|
|
|
|
#region Write
|
|
Task<Result<User?>> WriteUserAsync(User user);
|
|
/// <summary>Persists user with the given authorization. When null, current authorization is preserved.</summary>
|
|
Task<Result<User?>> WriteUserAsync(User user, UserAuthorization? authorization);
|
|
Task<Result> WriteUserAuthorizationAsync(UserAuthorization authorization);
|
|
#endregion
|
|
|
|
#region Delete
|
|
Task<Result> DeleteUserAsync(Guid userId);
|
|
#endregion
|
|
|
|
#region Login/Refresh/Logout
|
|
Task<Result<JwtToken?>> LoginAsync(string username, string password, string? twoFactorCode = null, string? twoFactorRecoveryCode = null);
|
|
Task<Result<JwtToken?>> RefreshTokenAsync(string refreshToken, bool? force = false);
|
|
Task<Result> LogoutAsync(Guid userId, string accessToken, bool allDevices = false);
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Enables two-factor authentication for the user: generates shared key and recovery codes, mutates the user.
|
|
/// </summary>
|
|
/// <returns>Plain recovery codes to show the user once; caller must persist the user after.</returns>
|
|
Result<List<string>?> EnableTwoFactorAuthForUser(User user);
|
|
}
|
|
|
|
public class IdentityDomainService(
|
|
ILogger<IdentityDomainService> logger,
|
|
IIdentityPersistenceService identityPersistenceService,
|
|
IUserAuthorizationPersistenceService userAuthorizationPersistenceService,
|
|
ICertsEngineConfiguration certsEngineConfiguration,
|
|
IAdminUser adminUser,
|
|
IJwtSettingsConfiguration jwtSettingsConfiguration,
|
|
ITwoFactorSettingsConfiguration twoFactorSettingsConfiguration
|
|
) : IIdentityDomainService {
|
|
|
|
private readonly ILogger<IdentityDomainService> _logger = logger;
|
|
private readonly IIdentityPersistenceService _identityPersistenceService = identityPersistenceService;
|
|
private readonly IUserAuthorizationPersistenceService _userAuthorizationPersistenceService = userAuthorizationPersistenceService;
|
|
private readonly ICertsEngineConfiguration _certsEngineConfiguration = certsEngineConfiguration;
|
|
private readonly IAdminUser _adminUser = adminUser;
|
|
private readonly IJwtSettingsConfiguration _jwtSettingsConfiguration = jwtSettingsConfiguration;
|
|
private readonly ITwoFactorSettingsConfiguration _twoFactorSettingsConfiguration = twoFactorSettingsConfiguration;
|
|
|
|
#region Read
|
|
public Result<User?> ReadUserById(Guid userId) =>
|
|
_identityPersistenceService.ReadById(userId);
|
|
|
|
public Result<User?> ReadUserByUsername(string usenrame) =>
|
|
_identityPersistenceService.ReadByUsername(usenrame);
|
|
|
|
public Result<UserAuthorization?> ReadUserAuthorization(Guid userId) =>
|
|
_userAuthorizationPersistenceService.ReadByUserId(userId);
|
|
#endregion
|
|
|
|
#region First boot Initialize
|
|
public async Task<Result<User?>> InitializeAdminAsync() {
|
|
var adminUserIdsResult = _userAuthorizationPersistenceService.ReadGlobalAdminUserIds();
|
|
if (adminUserIdsResult.IsSuccess && adminUserIdsResult.Value != null && adminUserIdsResult.Value.Count > 0) {
|
|
var firstId = adminUserIdsResult.Value[0];
|
|
var existing = _identityPersistenceService.ReadById(firstId);
|
|
if (existing.IsSuccess && existing.Value != null)
|
|
return existing;
|
|
return Result<User?>.BadRequest(null, "Global admin is referenced but the user record is missing.");
|
|
}
|
|
|
|
// Use the same pepper as login so hashed password matches validation; require it to be set (e.g. from appsecrets.json).
|
|
var pepper = _certsEngineConfiguration.JwtSettingsConfiguration?.PasswordPepper;
|
|
if (string.IsNullOrWhiteSpace(pepper)) {
|
|
return Result<User?>.BadRequest(null, "PasswordPepper is not set. Set Configuration:CertsEngineConfiguration:JwtSettingsConfiguration:PasswordPepper in appsecrets.json (or config) so bootstrap and login use the same pepper.");
|
|
}
|
|
|
|
var usernameTrimmed = (_adminUser.Username ?? "").Trim();
|
|
var passwordTrimmed = (_adminUser.Password ?? "").Trim();
|
|
if (string.IsNullOrWhiteSpace(usernameTrimmed) || string.IsNullOrWhiteSpace(passwordTrimmed)) {
|
|
return Result<User?>.BadRequest(null, "Admin Username and Password must be set in configuration.");
|
|
}
|
|
|
|
var user = new User(usernameTrimmed, passwordTrimmed, pepper!)
|
|
.SetIsActive(true);
|
|
|
|
var authorization = new UserAuthorization(user.Id).SetIsGlobalAdmin(true);
|
|
var upsertResult = _identityPersistenceService.Write(user, authorization);
|
|
if (!upsertResult.IsSuccess || upsertResult.Value == null)
|
|
return upsertResult.ToResultOfType<User?>(_ => null);
|
|
|
|
return upsertResult;
|
|
}
|
|
#endregion
|
|
|
|
#region Write
|
|
/// <summary>Persists user; loads current authorization from DB so auth is not overwritten.</summary>
|
|
public Task<Result<User?>> WriteUserAsync(User user) =>
|
|
WriteUserAsync(user, null);
|
|
|
|
/// <summary>Persists user and optional authorization. When authorization is null, loads current authorization from DB so auth is not overwritten.</summary>
|
|
public Task<Result<User?>> WriteUserAsync(User user, UserAuthorization? authorization) {
|
|
var authToApply = authorization;
|
|
if (authToApply == null) {
|
|
var authResult = _userAuthorizationPersistenceService.ReadByUserId(user.Id);
|
|
authToApply = authResult.IsSuccess ? authResult.Value : null;
|
|
}
|
|
return Task.FromResult(_identityPersistenceService.Write(user, authToApply));
|
|
}
|
|
|
|
public Task<Result> WriteUserAuthorizationAsync(UserAuthorization authorization) =>
|
|
Task.FromResult(_userAuthorizationPersistenceService.Write(authorization));
|
|
#endregion
|
|
|
|
#region Delete
|
|
public Task<Result> DeleteUserAsync(Guid userId) =>
|
|
Task.FromResult(_identityPersistenceService.DeleteById(userId));
|
|
#endregion
|
|
|
|
#region Login/Refresh/Logout
|
|
public async Task<Result<JwtToken?>> LoginAsync(string username, string password, string? twoFactorCode = null, string? twoFactorRecoveryCode = null) {
|
|
var usernameTrimmed = username?.Trim() ?? "";
|
|
var passwordTrimmed = password?.Trim() ?? "";
|
|
|
|
var userResult = _identityPersistenceService.ReadByUsername(usernameTrimmed);
|
|
|
|
if(!userResult.IsSuccess || userResult.Value == null) {
|
|
_logger.LogWarning("Login failed: user not found for username '{Username}' (trimmed: '{Trimmed}')", username ?? "(null)", usernameTrimmed);
|
|
return Result<JwtToken?>.Unauthorized(null, "Invalid username or password.");
|
|
}
|
|
|
|
var user = userResult.Value.RemoveRevokedTokens();
|
|
|
|
if(!user.IsActive) {
|
|
_logger.LogWarning("Login failed: user '{Username}' is not active", user.Username);
|
|
return Result<JwtToken?>.Unauthorized(null, "User is not active.");
|
|
}
|
|
|
|
var pepper = _certsEngineConfiguration.JwtSettingsConfiguration?.PasswordPepper;
|
|
if (string.IsNullOrWhiteSpace(pepper)) {
|
|
_logger.LogWarning("Login failed: PasswordPepper is not set (user '{Username}')", user.Username);
|
|
return Result<JwtToken?>.Unauthorized(null, "Invalid username or password.");
|
|
}
|
|
|
|
var validateHashResult = user.ValidatePassword(passwordTrimmed, pepper);
|
|
|
|
if (!validateHashResult.IsSuccess) {
|
|
_logger.LogWarning("Login failed: password validation failed for user '{Username}' (pepper length: {PepperLen}, password length: {PasswordLen})", user.Username, pepper?.Length ?? 0, passwordTrimmed.Length);
|
|
return Result<JwtToken?>.Unauthorized(null, "Invalid username or password.");
|
|
}
|
|
|
|
if (user.TwoFactorEnabled) {
|
|
if (string.IsNullOrWhiteSpace(twoFactorCode) && string.IsNullOrWhiteSpace(twoFactorRecoveryCode))
|
|
return Result<JwtToken?>.Unauthorized(null, "Two-factor authentication required.");
|
|
|
|
Result validateResult = Result.Ok();
|
|
|
|
if (!string.IsNullOrWhiteSpace(twoFactorCode))
|
|
validateResult = ValidateTwoFactorCodeForUser(user, twoFactorCode);
|
|
else if (!string.IsNullOrEmpty(twoFactorRecoveryCode))
|
|
validateResult = user.ValidateRecoveryCode(twoFactorRecoveryCode);
|
|
|
|
if (!validateResult.IsSuccess)
|
|
return validateResult.ToResultOfType<JwtToken?>((JwtToken?)null);
|
|
}
|
|
|
|
user.SetLastLogin();
|
|
|
|
var authResult = _userAuthorizationPersistenceService.ReadByUserId(user.Id);
|
|
var aclEntries = authResult.IsSuccess && authResult.Value != null
|
|
? authResult.Value.GetAclEntries()
|
|
: [];
|
|
|
|
var jwtTokenResult = IssueJwtForUser(user, aclEntries);
|
|
if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null)
|
|
return jwtTokenResult;
|
|
|
|
var writeUserResult = _identityPersistenceService.Write(user, authResult.IsSuccess ? authResult.Value : null);
|
|
if (!writeUserResult.IsSuccess || writeUserResult.Value == null)
|
|
return writeUserResult.ToResultOfType<JwtToken?>(_ => null);
|
|
|
|
return jwtTokenResult;
|
|
}
|
|
|
|
public async Task<Result<JwtToken?>> RefreshTokenAsync(string refreshToken, bool? force = false) {
|
|
var userResult = _identityPersistenceService.ReadByRefreshToken(refreshToken);
|
|
if (!userResult.IsSuccess || userResult.Value == null)
|
|
return Result<JwtToken?>.Unauthorized(null, "Invalid refresh token.");
|
|
|
|
var user = userResult.Value.RemoveRevokedTokens();
|
|
|
|
var token = user.Tokens.SingleOrDefault(token => token.RefreshToken == refreshToken);
|
|
|
|
if (token == null)
|
|
return Result<JwtToken?>.Unauthorized(null, "Invalid refresh token.");
|
|
|
|
if (token.IsRevoked)
|
|
return Result<JwtToken?>.Unauthorized(null, "Token has been revoked.");
|
|
|
|
Result<User?>? writeResult = default;
|
|
|
|
// If refresh token is still valid and force is not set, do nothing, just return the same token
|
|
if (DateTime.UtcNow <= token.ExpiresAt && force != true) {
|
|
user.SetLastLogin();
|
|
|
|
// Update the user status in the database
|
|
var authForRefresh = _userAuthorizationPersistenceService.ReadByUserId(user.Id);
|
|
writeResult = _identityPersistenceService.Write(user, authForRefresh.IsSuccess ? authForRefresh.Value : null);
|
|
if (!writeResult.IsSuccess || writeResult.Value == null)
|
|
return writeResult.ToResultOfType<JwtToken?>(_ => null);
|
|
|
|
return Result<JwtToken?>.Ok(token);
|
|
}
|
|
|
|
// If refresh token is expired, it cannot be used to get a new token; remove it and persist.
|
|
if (DateTime.UtcNow > token.RefreshTokenExpiresAt) {
|
|
user.RemoveToken(token.Id);
|
|
var authForRemove = _userAuthorizationPersistenceService.ReadByUserId(user.Id);
|
|
_identityPersistenceService.Write(user, authForRemove.IsSuccess ? authForRemove.Value : null);
|
|
return Result<JwtToken?>.Unauthorized(null, "Refresh token has expired.");
|
|
}
|
|
|
|
user.SetLastLogin();
|
|
|
|
var authResult = _userAuthorizationPersistenceService.ReadByUserId(user.Id);
|
|
var aclEntries = authResult.IsSuccess && authResult.Value != null
|
|
? authResult.Value.GetAclEntries()
|
|
: [];
|
|
|
|
var jwtTokenResult = IssueJwtForUser(user, aclEntries);
|
|
if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null)
|
|
return jwtTokenResult;
|
|
|
|
// Update the user status in the database
|
|
writeResult = _identityPersistenceService.Write(user, authResult.IsSuccess ? authResult.Value : null);
|
|
if (!writeResult.IsSuccess || writeResult.Value == null)
|
|
return writeResult.ToResultOfType<JwtToken?>(_ => null);
|
|
|
|
return jwtTokenResult;
|
|
}
|
|
|
|
/// <summary>Logs out by user id and access token from the JWT payload; removes the session or all sessions.</summary>
|
|
public async Task<Result> LogoutAsync(Guid userId, string accessToken, bool allDevices = false) {
|
|
var userResult = _identityPersistenceService.ReadById(userId);
|
|
if (!userResult.IsSuccess || userResult.Value == null)
|
|
return Result.Ok();
|
|
|
|
var user = userResult.Value;
|
|
|
|
if (allDevices)
|
|
user.RemoveAllTokens();
|
|
else
|
|
user.RemoveToken(accessToken);
|
|
|
|
var authResult = _userAuthorizationPersistenceService.ReadByUserId(user.Id);
|
|
_identityPersistenceService.Write(user, authResult.IsSuccess ? authResult.Value : null);
|
|
return Result.Ok();
|
|
}
|
|
#endregion
|
|
|
|
#region Two-factor and JWT orchestration
|
|
private const int TwoFactorRecoveryCodeCount = 5;
|
|
|
|
public Result<List<string>?> EnableTwoFactorAuthForUser(User user) {
|
|
if (user.TwoFactorEnabled)
|
|
return Result<List<string>?>.BadRequest(null, "Two-factor authentication is already enabled.");
|
|
|
|
var pepper = _certsEngineConfiguration.JwtSettingsConfiguration.PasswordPepper;
|
|
if (string.IsNullOrWhiteSpace(pepper))
|
|
return Result<List<string>?>.InternalServerError(null, "Password pepper is not configured.");
|
|
|
|
if (!TotpGenerator.TryGenerateSecret(out string? secret, out string? errorMessage))
|
|
return Result<List<string>?>.InternalServerError(null, errorMessage);
|
|
|
|
user.SetTwoFactorSharedKey(secret);
|
|
|
|
var clearRecoveryCodes = new List<string>(TwoFactorRecoveryCodeCount);
|
|
List<TwoFactorRecoveryCode> recoveryCodeEntities = [];
|
|
|
|
for (int i = 0; i < TwoFactorRecoveryCodeCount; i++) {
|
|
var code = Guid.NewGuid().ToString("N")[..8];
|
|
var formattedCode = $"{code[..4]}-{code[4..8]}";
|
|
clearRecoveryCodes.Add(formattedCode);
|
|
}
|
|
|
|
foreach (var code in clearRecoveryCodes) {
|
|
if (!PasswordHasher.TryCreateSaltedHash(code, pepper, out (string Salt, string Hash)? saltedHash, out string? hashError))
|
|
return Result<List<string>?>.InternalServerError(null, hashError);
|
|
|
|
var (salt, hash) = saltedHash.Value;
|
|
recoveryCodeEntities.Add(new TwoFactorRecoveryCode(salt, pepper, hash));
|
|
}
|
|
|
|
user.SetTwoFactorRecoveryCodes(recoveryCodeEntities);
|
|
return Result<List<string>?>.Ok(clearRecoveryCodes);
|
|
}
|
|
|
|
private Result ValidateTwoFactorCodeForUser(User user, string code) {
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
return Result.BadRequest("Code is required.");
|
|
|
|
if (!user.TwoFactorEnabled || string.IsNullOrEmpty(user.TwoFactorSharedKey))
|
|
return Result.BadRequest("Two-factor authentication is not enabled.");
|
|
|
|
if (!TotpGenerator.TryValidate(code, user.TwoFactorSharedKey!, _twoFactorSettingsConfiguration.TimeTolerance, out bool isValid, out string? errorMessage))
|
|
return Result.InternalServerError(errorMessage);
|
|
|
|
return isValid ? Result.Ok() : Result.Unauthorized("Invalid two-factor code.");
|
|
}
|
|
|
|
private Result<JwtToken?> IssueJwtForUser(User user, IReadOnlyList<string> aclEntries) {
|
|
if (!JwtGenerator.TryGenerateToken(new JWTTokenGenerateRequest {
|
|
Secret = _jwtSettingsConfiguration.JwtSecret,
|
|
Issuer = _jwtSettingsConfiguration.Issuer,
|
|
Audience = _jwtSettingsConfiguration.Audience,
|
|
Expiration = _jwtSettingsConfiguration.ExpiresIn,
|
|
UserId = user.Id.ToString(),
|
|
Username = user.Username,
|
|
AclEntries = aclEntries?.ToList() ?? []
|
|
}, out (string token, JWTTokenClaims claims)? tokenData, out string? errorMessage)) {
|
|
return Result<JwtToken?>.InternalServerError(null, errorMessage);
|
|
}
|
|
|
|
var (tokenString, claims) = tokenData.Value;
|
|
if (claims.IssuedAt == null || claims.ExpiresAt == null)
|
|
return Result<JwtToken?>.InternalServerError(null, "Invalid token claims: IssuedAt or ExpiresAt is null.");
|
|
|
|
string refreshToken = JwtGenerator.GenerateRefreshToken();
|
|
var jwtToken = new JwtToken(
|
|
tokenString,
|
|
claims.IssuedAt.Value,
|
|
claims.ExpiresAt.Value,
|
|
refreshToken,
|
|
claims.IssuedAt.Value.AddDays(_jwtSettingsConfiguration.RefreshTokenExpiresIn)
|
|
);
|
|
|
|
user.RecordIssuedToken(jwtToken);
|
|
return Result<JwtToken?>.Ok(jwtToken);
|
|
}
|
|
#endregion
|
|
}
|