mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): login, refresh and logout
This commit is contained in:
parent
c6dbf6d195
commit
5d697fbcef
@ -8,6 +8,7 @@ Simple client to obtain Let's Encrypt HTTPS certificates developed with .net cor
|
|||||||
* 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation)
|
* 01 Nov, 2019 - V2.0 (Dependency Injection pattern impelemtation)
|
||||||
* 31 May, 2024 - V3.0 (Webapi and containerization)
|
* 31 May, 2024 - V3.0 (Webapi and containerization)
|
||||||
* 11 Aug, 2024 - V3.1 (Release)
|
* 11 Aug, 2024 - V3.1 (Release)
|
||||||
|
* 11 Sep, 2025 - V3.2 New WebUI with authentication
|
||||||
|
|
||||||
## Haproxy configuration
|
## Haproxy configuration
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ namespace MaksIT.LetsEncryptServer {
|
|||||||
|
|
||||||
public required string CacheFolder { get; set; }
|
public required string CacheFolder { get; set; }
|
||||||
public required string AcmeFolder { get; set; }
|
public required string AcmeFolder { get; set; }
|
||||||
|
public required string DataFolder { get; set; }
|
||||||
|
|
||||||
public required Agent Agent { get; set; }
|
public required Agent Agent { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,28 +22,18 @@ public class IdentityController(
|
|||||||
return result.ToActionResult();
|
return result.ToActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
//[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
//[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||||
//public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest requestData) {
|
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest requestData) {
|
||||||
// var result = await _identityService.RefreshTokenAsync(requestData);
|
var result = await _identityService.RefreshTokenAsync(requestData);
|
||||||
// return result.ToActionResult();
|
return result.ToActionResult();
|
||||||
//}
|
}
|
||||||
|
|
||||||
//[ServiceFilter(typeof(JwtAuthorizationFilter))]
|
[HttpPost("logout")]
|
||||||
//[HttpPost("logout")]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
//[ProducesResponseType(StatusCodes.Status200OK)]
|
public async Task<IActionResult> Logout([FromBody] LogoutRequest requestData) {
|
||||||
//public async Task<IActionResult> Logout([FromBody] LogoutRequest requetData) {
|
var result = await _identityService.Logout(requestData);
|
||||||
// var jwtTokenDataResult = HttpContext.GetJwtTokenData();
|
return result.ToActionResult();
|
||||||
// if (!jwtTokenDataResult.IsSuccess || jwtTokenDataResult.Value == null)
|
}
|
||||||
// return jwtTokenDataResult.ToActionResult();
|
|
||||||
|
|
||||||
// var jwtTokenData = jwtTokenDataResult.Value;
|
|
||||||
|
|
||||||
// var result = await _identityService.Logout(jwtTokenData, requetData);
|
|
||||||
// return result.ToActionResult();
|
|
||||||
//}
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/LetsEncryptServer/Domain/JwtToken.cs
Normal file
65
src/LetsEncryptServer/Domain/JwtToken.cs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
using MaksIT.Core.Abstractions.Domain;
|
||||||
|
using System.Linq.Dynamic.Core.Tokenizer;
|
||||||
|
|
||||||
|
namespace MaksIT.LetsEncryptServer.Domain;
|
||||||
|
|
||||||
|
public class JwtToken(Guid id) : DomainDocumentBase<Guid>(id) {
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a JSON Web Token (JWT) used for authentication and authorization.
|
||||||
|
/// </summary>
|
||||||
|
public string TokenType { get; private set; } = "Bearer";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The actual JWT token string.
|
||||||
|
/// </summary>
|
||||||
|
public string Token { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time when the JWT was issued.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime IssuedAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time when the JWT will expire.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ExpiresAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the JWT has been revoked.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRevoked { get; private set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The refresh token associated with this JWT, used to obtain a new JWT when the current one expires.
|
||||||
|
/// </summary>
|
||||||
|
public string RefreshToken { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time when the refresh token will expire.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RefreshTokenExpiresAt { get; private set; }
|
||||||
|
|
||||||
|
public JwtToken() : this(Guid.NewGuid()) { }
|
||||||
|
|
||||||
|
public JwtToken SetAccessTokenData(
|
||||||
|
string token,
|
||||||
|
DateTime issuedAt,
|
||||||
|
DateTime expiresAt
|
||||||
|
) {
|
||||||
|
Token = token;
|
||||||
|
IssuedAt = issuedAt;
|
||||||
|
ExpiresAt = expiresAt;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JwtToken SetRefreshTokenData(
|
||||||
|
string refreshToken,
|
||||||
|
DateTime refreshTokenExpiresAt
|
||||||
|
) {
|
||||||
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ public class Settings : DomainObjectBase {
|
|||||||
public bool Init { get; set; }
|
public bool Init { get; set; }
|
||||||
public List<User> Users { get; set; } = [];
|
public List<User> Users { get; set; } = [];
|
||||||
|
|
||||||
public Settings() {}
|
public Settings() { }
|
||||||
|
|
||||||
public Result<Settings?> Initialize(string pepper) {
|
public Result<Settings?> Initialize(string pepper) {
|
||||||
var userResult = new User("admin")
|
var userResult = new User("admin")
|
||||||
@ -25,15 +25,28 @@ public class Settings : DomainObjectBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Result<User?> GetUserByName(string name) {
|
public Result<User?> GetUserByName(string name) {
|
||||||
|
|
||||||
var user = Users.FirstOrDefault(x => x.Name == name);
|
var user = Users.FirstOrDefault(x => x.Name == name);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Result<User?>.NotFound(null, "User not found.");
|
return Result<User?>.NotFound(null, "User not found.");
|
||||||
|
|
||||||
return Result<User?>.Ok(user);
|
return Result<User?>.Ok(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Result<User?> GetByJwtToken(string token) {
|
||||||
|
var user = Users.FirstOrDefault(u => u.JwtTokens.Any(t => t.Token == token));
|
||||||
|
if (user == null)
|
||||||
|
return Result<User?>.NotFound(null, "User not found.");
|
||||||
|
return Result<User?>.Ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<User?> GetByRefreshToken(string refreshToken) {
|
||||||
|
var user = Users.FirstOrDefault(u => u.JwtTokens.Any(t => t.RefreshToken == refreshToken));
|
||||||
|
if (user == null)
|
||||||
|
return Result<User?>.NotFound(null, "User not found for the provided refresh token.");
|
||||||
|
|
||||||
|
return Result<User?>.Ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
public Result<Settings?> AddUser(string name, string password, string pepper) {
|
public Result<Settings?> AddUser(string name, string password, string pepper) {
|
||||||
var setPasswordResult = new User(name)
|
var setPasswordResult = new User(name)
|
||||||
.SetPassword(password, pepper);
|
.SetPassword(password, pepper);
|
||||||
@ -48,6 +61,21 @@ public class Settings : DomainObjectBase {
|
|||||||
return Result<Settings?>.Ok(this);
|
return Result<Settings?>.Ok(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Settings UpsertUser(User user) {
|
||||||
|
var existing = Users.FirstOrDefault(u => u.Id == user.Id);
|
||||||
|
if (existing != null)
|
||||||
|
Users.Remove(existing);
|
||||||
|
Users.Add(user);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Settings UpsertUsers(List<User> users) {
|
||||||
|
foreach (var user in users)
|
||||||
|
UpsertUser(user);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public Result<Settings?> RemoveUser(string name) {
|
public Result<Settings?> RemoveUser(string name) {
|
||||||
if (Users.Any(x => x.Name == name)) {
|
if (Users.Any(x => x.Name == name)) {
|
||||||
Users = [.. Users.Where(u => u.Name != name)];
|
Users = [.. Users.Where(u => u.Name != name)];
|
||||||
@ -56,4 +84,21 @@ public class Settings : DomainObjectBase {
|
|||||||
|
|
||||||
return Result<Settings?>.NotFound(null, "User not found.");
|
return Result<Settings?>.NotFound(null, "User not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Result<Settings?> RemoveUser(Guid userId) {
|
||||||
|
var user = Users.FirstOrDefault(u => u.Id == userId);
|
||||||
|
if (user == null)
|
||||||
|
return Result<Settings?>.NotFound(null, "User not found.");
|
||||||
|
Users.Remove(user);
|
||||||
|
return Result<Settings?>.Ok(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<Settings?> RemoveUsers(List<Guid> userIds) {
|
||||||
|
foreach (var userId in userIds) {
|
||||||
|
var removeResult = RemoveUser(userId);
|
||||||
|
if (!removeResult.IsSuccess)
|
||||||
|
return removeResult;
|
||||||
|
}
|
||||||
|
return Result<Settings?>.Ok(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -7,10 +7,12 @@ namespace MaksIT.LetsEncryptServer.Domain;
|
|||||||
public class User(
|
public class User(
|
||||||
Guid id,
|
Guid id,
|
||||||
string name
|
string name
|
||||||
) : DomainDocumentBase<Guid>(id) {
|
) : DomainDocumentBase<Guid>(id) {
|
||||||
public string Name { get; private set; } = name;
|
public string Name { get; private set; } = name;
|
||||||
public string Salt { get; private set; } = string.Empty;
|
public string Salt { get; private set; } = string.Empty;
|
||||||
public string Hash { get; private set; } = string.Empty;
|
public string Hash { get; private set; } = string.Empty;
|
||||||
|
public List<JwtToken> JwtTokens { get; private set; } = [];
|
||||||
|
public DateTime LastLogin { get; private set; }
|
||||||
|
|
||||||
public User(
|
public User(
|
||||||
string name
|
string name
|
||||||
@ -19,8 +21,57 @@ public class User(
|
|||||||
name
|
name
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Change user name
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newName"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="ArgumentException"></exception>
|
||||||
|
public User SetName(string newName) {
|
||||||
|
Name = newName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
// Set or change password (returns this for chaining)
|
/// <summary>
|
||||||
|
/// For persistence
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="salt"></param>
|
||||||
|
/// <param name="hash"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public User SetSaltedHash(string salt, string hash) {
|
||||||
|
Salt = salt;
|
||||||
|
Hash = hash;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokens"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public User SetJwtTokens(List<JwtToken> tokens) {
|
||||||
|
JwtTokens = tokens;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User SetLastLogin() {
|
||||||
|
SetLastLogin(DateTime.UtcNow);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User SetLastLogin(DateTime dateTime) {
|
||||||
|
LastLogin = dateTime;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Password Management
|
||||||
|
/// <summary>
|
||||||
|
/// Set or change password (returns this for chaining)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="password"></param>
|
||||||
|
/// <param name="pepper"></param>
|
||||||
|
/// <returns></returns>
|
||||||
public Result<User?> SetPassword(string password, string pepper) {
|
public Result<User?> SetPassword(string password, string pepper) {
|
||||||
if (!PasswordHasher.TryCreateSaltedHash(password, pepper, out var saltedHash, out var errorMessage))
|
if (!PasswordHasher.TryCreateSaltedHash(password, pepper, out var saltedHash, out var errorMessage))
|
||||||
return Result<User?>.InternalServerError(null, errorMessage);
|
return Result<User?>.InternalServerError(null, errorMessage);
|
||||||
@ -31,18 +82,12 @@ public class User(
|
|||||||
return Result<User?>.Ok(this);
|
return Result<User?>.Ok(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset password to a new value (returns this for chaining)
|
/// <summary>
|
||||||
public Result<User?> ResetPassword(string newPassword, string pepper) => SetPassword(newPassword, pepper);
|
/// Validate password
|
||||||
|
/// </summary>
|
||||||
// Change user name
|
/// <param name="password"></param>
|
||||||
public User ChangeName(string newName) {
|
/// <param name="pepper"></param>
|
||||||
if (string.IsNullOrWhiteSpace(newName))
|
/// <returns></returns>
|
||||||
throw new ArgumentException("Name cannot be empty.", nameof(newName));
|
|
||||||
Name = newName;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate password
|
|
||||||
public Result ValidatePassword(string password, string pepper) {
|
public Result ValidatePassword(string password, string pepper) {
|
||||||
if (PasswordHasher.TryValidateHash(password, Salt, Hash, pepper, out var isValid, out var errorMessage)) {
|
if (PasswordHasher.TryValidateHash(password, Salt, Hash, pepper, out var isValid, out var errorMessage)) {
|
||||||
if (isValid)
|
if (isValid)
|
||||||
@ -54,11 +99,72 @@ public class User(
|
|||||||
return Result<User?>.InternalServerError(null, errorMessage);
|
return Result<User?>.InternalServerError(null, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For persistence
|
/// <summary>
|
||||||
public User SeltSaltedHash(string salt, string hash) {
|
/// Reset password to a new value (returns this for chaining)
|
||||||
Salt = salt;
|
/// </summary>
|
||||||
Hash = hash;
|
/// <param name="newPassword"></param>
|
||||||
|
/// <param name="pepper"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public Result<User?> ResetPassword(string newPassword, string pepper) => SetPassword(newPassword, pepper);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region JWT Token Management
|
||||||
|
public User UpsertJwtToken(JwtToken token) {
|
||||||
|
var existing = JwtTokens.FirstOrDefault(t => t.Id == token.Id);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
JwtTokens.Remove(existing);
|
||||||
|
|
||||||
|
JwtTokens.Add(token);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public User UpsertJwtTokens(List<JwtToken> tokens) {
|
||||||
|
foreach (var token in tokens)
|
||||||
|
UpsertJwtToken(token);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<User?> RemoveJwtToken(Guid tokenId) {
|
||||||
|
var token = JwtTokens.FirstOrDefault(t => t.Id == tokenId);
|
||||||
|
if (token == null)
|
||||||
|
return Result<User?>.NotFound(null, "JWT token not found.");
|
||||||
|
|
||||||
|
JwtTokens.Remove(token);
|
||||||
|
return Result<User?>.Ok(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<User?> RemoveJwtToken(string token) {
|
||||||
|
var tokenDomain = JwtTokens.FirstOrDefault(t => t.Token == token);
|
||||||
|
if (tokenDomain == null)
|
||||||
|
return Result<User?>.NotFound(null, "JWT token not found.");
|
||||||
|
JwtTokens.Remove(tokenDomain);
|
||||||
|
return Result<User?>.Ok(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<User?> RemoveJwtTokens(List<Guid> tokenIds) {
|
||||||
|
|
||||||
|
foreach (var tokenId in tokenIds) {
|
||||||
|
var removeTokenResult = RemoveJwtToken(tokenId);
|
||||||
|
|
||||||
|
if (!removeTokenResult.IsSuccess)
|
||||||
|
return removeTokenResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<User?>.Ok(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public User RemoveRevokedJwtTokens() {
|
||||||
|
JwtTokens = JwtTokens.Where(t => !t.IsRevoked).ToList();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User RevokeAllJwtTokens() {
|
||||||
|
JwtTokens = [];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
12
src/LetsEncryptServer/Dto/JwtTokenDto.cs
Normal file
12
src/LetsEncryptServer/Dto/JwtTokenDto.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using MaksIT.Core.Abstractions.Dto;
|
||||||
|
|
||||||
|
namespace MaksIT.LetsEncryptServer.Dto;
|
||||||
|
|
||||||
|
public class JwtTokenDto : DtoDocumentBase<Guid> {
|
||||||
|
public required string Token { get; set; }
|
||||||
|
public DateTime IssuedAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public required string RefreshToken { get; set; }
|
||||||
|
public DateTime RefreshTokenExpiresAt { get; set; }
|
||||||
|
public bool IsRevoked { get; set; } = false;
|
||||||
|
}
|
||||||
@ -3,4 +3,5 @@
|
|||||||
public class SettingsDto {
|
public class SettingsDto {
|
||||||
public required bool Init { get; set; }
|
public required bool Init { get; set; }
|
||||||
public required List<UserDto> Users { get; set; } = [];
|
public required List<UserDto> Users { get; set; } = [];
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,8 +1,11 @@
|
|||||||
namespace MaksIT.LetsEncryptServer.Dto;
|
using MaksIT.Core.Abstractions.Dto;
|
||||||
|
|
||||||
public class UserDto {
|
namespace MaksIT.LetsEncryptServer.Dto;
|
||||||
public required string Id { get; set; }
|
|
||||||
public required string Name { get; set; }
|
public class UserDto : DtoDocumentBase<Guid> {
|
||||||
public required string Salt { get; set; }
|
public required string Name { get; set; } = string.Empty;
|
||||||
public required string Hash { get; set; }
|
public required string Salt { get; set; } = string.Empty;
|
||||||
|
public required string Hash { get; set; } = string.Empty;
|
||||||
|
public required List<JwtTokenDto> JwtTokens { get; set; } = [];
|
||||||
|
public required DateTime LastLogin { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,15 +56,33 @@ public class CertsFlowService : ICertsFlowService {
|
|||||||
var termsOfServiceUrl = result.Value;
|
var termsOfServiceUrl = result.Value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var pdfBytesTask = _httpClient.GetByteArrayAsync(termsOfServiceUrl);
|
var fileName = Path.GetFileName(new Uri(termsOfServiceUrl).LocalPath);
|
||||||
pdfBytesTask.Wait();
|
|
||||||
var pdfBytes = pdfBytesTask.Result;
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
var base64 = Convert.ToBase64String(pdfBytes);
|
||||||
return Result<string?>.Ok(base64);
|
return Result<string?>.Ok(base64);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError(ex, "Failed to download 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 or convert Terms of Service PDF: {ex.Message}");
|
return Result<string?>.InternalServerError(null, $"Failed to download, cache, or convert Terms of Service PDF: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ using MaksIT.Results;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Models.LetsEncryptServer.Identity.Login;
|
using Models.LetsEncryptServer.Identity.Login;
|
||||||
using Models.LetsEncryptServer.Identity.Logout;
|
using Models.LetsEncryptServer.Identity.Logout;
|
||||||
|
using System.Linq.Dynamic.Core.Tokenizer;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace MaksIT.LetsEncryptServer.Services;
|
namespace MaksIT.LetsEncryptServer.Services;
|
||||||
@ -12,8 +13,8 @@ namespace MaksIT.LetsEncryptServer.Services;
|
|||||||
public interface IIdentityService {
|
public interface IIdentityService {
|
||||||
#region Login/Refresh/Logout
|
#region Login/Refresh/Logout
|
||||||
Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData);
|
Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData);
|
||||||
//Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData);
|
Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData);
|
||||||
//Task<Result> Logout(JwtTokenData jwtTokenData, LogoutRequest requestData);
|
Task<Result> Logout(LogoutRequest requestData);
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,45 +65,132 @@ public class IdentityService(
|
|||||||
|
|
||||||
string refreshToken = JwtGenerator.GenerateRefreshToken();
|
string refreshToken = JwtGenerator.GenerateRefreshToken();
|
||||||
|
|
||||||
|
var tokenDomain = new JwtToken()
|
||||||
|
.SetAccessTokenData(token, claims.IssuedAt.Value, claims.ExpiresAt.Value)
|
||||||
|
.SetRefreshTokenData(refreshToken, claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration));
|
||||||
|
|
||||||
|
user.UpsertJwtToken(tokenDomain);
|
||||||
|
user.SetLastLogin();
|
||||||
|
settings.UpsertUser(user);
|
||||||
|
|
||||||
|
var saveSettingsResult = await _settingsService.SaveAsync(settings);
|
||||||
|
if (!saveSettingsResult.IsSuccess)
|
||||||
|
return saveSettingsResult.ToResultOfType<LoginResponse?>(default);
|
||||||
|
|
||||||
var response = new LoginResponse {
|
var response = new LoginResponse {
|
||||||
TokenType = "Bearer",
|
TokenType = tokenDomain.TokenType,
|
||||||
Token = token,
|
Token = tokenDomain.Token,
|
||||||
ExpiresAt = claims.ExpiresAt.Value,
|
ExpiresAt = claims.ExpiresAt.Value,
|
||||||
RefreshToken = refreshToken,
|
RefreshToken = tokenDomain.RefreshToken,
|
||||||
RefreshTokenExpiresAt = claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration)
|
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||||
};
|
};
|
||||||
|
|
||||||
return Result<LoginResponse?>.Ok(response);
|
return Result<LoginResponse?>.Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData) {
|
||||||
|
var loadSettingsResult = await _settingsService.LoadAsync();
|
||||||
|
if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == 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.");
|
||||||
|
|
||||||
|
var user = userResult.Value.RemoveRevokedJwtTokens();
|
||||||
|
var tokenDomain = user.JwtTokens.SingleOrDefault(t => t.RefreshToken == requestData.RefreshToken);
|
||||||
|
|
||||||
//public async Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData) {
|
if (tokenDomain == null)
|
||||||
// return await HandleTokenResponseAsync(() =>
|
return Result<LoginResponse?>.Unauthorized(null, "Invalid refresh token.");
|
||||||
// _identityDomainService.RefreshTokenAsync(requestData.RefreshToken));
|
|
||||||
//}
|
|
||||||
|
|
||||||
//private static async Task<Result<LoginResponse?>> HandleTokenResponseAsync(Func<Task<Result<JwtToken?>>> tokenOperation) {
|
// Token is still valid
|
||||||
// var jwtTokenResult = await tokenOperation();
|
if (DateTime.UtcNow <= tokenDomain.ExpiresAt) {
|
||||||
// if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null)
|
user.SetLastLogin();
|
||||||
// return jwtTokenResult.ToResultOfType<LoginResponse?>(_ => null);
|
settings.UpsertUser(user);
|
||||||
|
|
||||||
// var jwtToken = jwtTokenResult.Value;
|
var saveResult = await _settingsService.SaveAsync(settings);
|
||||||
|
if (!saveResult.IsSuccess)
|
||||||
|
return saveResult.ToResultOfType<LoginResponse?>(default);
|
||||||
|
|
||||||
// return Result<LoginResponse?>.Ok(new LoginResponse {
|
return Result<LoginResponse?>.Ok(new LoginResponse {
|
||||||
// TokenType = jwtToken.TokenType,
|
TokenType = tokenDomain.TokenType,
|
||||||
// Token = jwtToken.Token,
|
Token = tokenDomain.Token,
|
||||||
// ExpiresAt = jwtToken.ExpiresAt,
|
ExpiresAt = tokenDomain.ExpiresAt,
|
||||||
// RefreshToken = jwtToken.RefreshToken,
|
RefreshToken = tokenDomain.RefreshToken,
|
||||||
// RefreshTokenExpiresAt = jwtToken.RefreshTokenExpiresAt
|
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||||
// });
|
});
|
||||||
//}
|
}
|
||||||
|
|
||||||
//public async Task<Result> Logout(JwtTokenData jwtTokenData, LogoutRequest requestData) {
|
// Refresh token expired
|
||||||
// var logoutResult = await _identityDomainService.LogoutAsync(jwtTokenData.Username, jwtTokenData.Token, requestData.LogoutFromAllDevices);
|
if (DateTime.UtcNow > tokenDomain.RefreshTokenExpiresAt) {
|
||||||
// return logoutResult;
|
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,
|
||||||
|
}, out (string token, JWTTokenClaims claims)? tokenData, out string? 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.");
|
||||||
|
|
||||||
|
string refreshToken = JwtGenerator.GenerateRefreshToken();
|
||||||
|
|
||||||
|
tokenDomain = new JwtToken()
|
||||||
|
.SetAccessTokenData(token, claims.IssuedAt.Value, claims.ExpiresAt.Value)
|
||||||
|
.SetRefreshTokenData(refreshToken, claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration));
|
||||||
|
|
||||||
|
user.UpsertJwtToken(tokenDomain);
|
||||||
|
user.SetLastLogin();
|
||||||
|
settings.UpsertUser(user);
|
||||||
|
|
||||||
|
var writeResult = await _settingsService.SaveAsync(settings);
|
||||||
|
if (!writeResult.IsSuccess)
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Logout(LogoutRequest requestData) {
|
||||||
|
var loadSettingsResult = await _settingsService.LoadAsync();
|
||||||
|
if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null)
|
||||||
|
return loadSettingsResult.ToResultOfType<LoginResponse?>(_ => null);
|
||||||
|
|
||||||
|
var settings = loadSettingsResult.Value;
|
||||||
|
|
||||||
|
var userResult = settings.GetByJwtToken(requestData.Token);
|
||||||
|
if (userResult.IsSuccess && userResult.Value != null) {
|
||||||
|
var user = userResult.Value;
|
||||||
|
|
||||||
|
if (requestData.LogoutFromAllDevices)
|
||||||
|
user.RevokeAllJwtTokens();
|
||||||
|
else
|
||||||
|
user.RemoveJwtToken(requestData.Token);
|
||||||
|
|
||||||
|
settings.UpsertUser(user);
|
||||||
|
|
||||||
|
var writeUserResult = await settingsService.SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ using System.Threading.Tasks;
|
|||||||
namespace MaksIT.LetsEncryptServer.Services;
|
namespace MaksIT.LetsEncryptServer.Services;
|
||||||
|
|
||||||
public interface ISettingsService {
|
public interface ISettingsService {
|
||||||
Task<Result<Settings>> LoadAsync();
|
Task<Result<Settings?>> LoadAsync();
|
||||||
Task<Result> SaveAsync(Settings settings);
|
Task<Result> SaveAsync(Settings settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,24 +31,34 @@ public class SettingsService : ISettingsService, IDisposable {
|
|||||||
|
|
||||||
#region Internal I/O
|
#region Internal I/O
|
||||||
|
|
||||||
private async Task<Result<Settings>> LoadInternalAsync() {
|
private async Task<Result<Settings?>> LoadInternalAsync() {
|
||||||
try {
|
try {
|
||||||
if (!File.Exists(_settingsPath))
|
if (!File.Exists(_settingsPath))
|
||||||
return Result<Settings>.Ok(new Settings());
|
return Result<Settings?>.Ok(new Settings());
|
||||||
|
|
||||||
var json = await File.ReadAllTextAsync(_settingsPath);
|
var json = await File.ReadAllTextAsync(_settingsPath);
|
||||||
var settingsDto = json.ToObject<SettingsDto>();
|
var settingsDto = json.ToObject<SettingsDto>();
|
||||||
if (settingsDto == null)
|
if (settingsDto == null)
|
||||||
return Result<Settings>.InternalServerError(new Settings(), "Settings file is invalid or empty.");
|
return Result<Settings?>.InternalServerError(new Settings(), "Settings file is invalid or empty.");
|
||||||
|
|
||||||
var settings = new Settings {
|
var settings = new Settings {
|
||||||
Init = settingsDto.Init,
|
Init = settingsDto.Init,
|
||||||
Users = [.. settingsDto.Users.Select(userDto => new User(userDto.Id.ToGuid(), userDto.Name).SeltSaltedHash(userDto.Salt, userDto.Hash))]
|
Users = [.. settingsDto.Users.Select(userDto => new User(userDto.Id, 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);
|
return Result<Settings?>.Ok(settings);
|
||||||
} catch (Exception ex) {
|
}
|
||||||
_logger.LogError(ex, "Error loading settings file.");
|
catch (Exception ex) {
|
||||||
return Result<Settings>.InternalServerError(new Settings(), ex.Message);
|
var message = "Error loading settings file.";
|
||||||
|
_logger.LogError(ex, message);
|
||||||
|
return Result<Settings?>.InternalServerError(null, new[] { message }.Concat(ex.ExtractMessages()).ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,24 +67,37 @@ public class SettingsService : ISettingsService, IDisposable {
|
|||||||
var settingsDto = new SettingsDto {
|
var settingsDto = new SettingsDto {
|
||||||
Init = settings.Init,
|
Init = settings.Init,
|
||||||
Users = [.. settings.Users.Select(u => new UserDto {
|
Users = [.. settings.Users.Select(u => new UserDto {
|
||||||
Id = u.Id.ToString(),
|
Id = u.Id,
|
||||||
Name = u.Name,
|
Name = u.Name,
|
||||||
Salt = u.Salt,
|
Salt = u.Salt,
|
||||||
Hash = u.Hash
|
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());
|
await File.WriteAllTextAsync(_settingsPath, settingsDto.ToJson());
|
||||||
_logger.LogInformation("Settings file saved.");
|
_logger.LogInformation("Settings file saved.");
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
} catch (Exception ex) {
|
}
|
||||||
_logger.LogError(ex, "Error saving settings file.");
|
catch (Exception ex) {
|
||||||
return Result.InternalServerError(ex.Message);
|
var message = "Error saving settings file.";
|
||||||
|
_logger.LogError(ex, message);
|
||||||
|
return Result.InternalServerError([message, .. ex.ExtractMessages()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public async Task<Result<Settings>> LoadAsync() {
|
public async Task<Result<Settings?>> LoadAsync() {
|
||||||
return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync());
|
return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
"CacheFolder": "/cache",
|
"CacheFolder": "/cache",
|
||||||
"AcmeFolder": "/acme",
|
"AcmeFolder": "/acme",
|
||||||
|
"DataFolder": "/data",
|
||||||
|
|
||||||
"Agent": {
|
"Agent": {
|
||||||
"AgentHostname": "",
|
"AgentHostname": "",
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { z } from 'zod'
|
import { boolean, object, Schema, string } from 'zod'
|
||||||
|
|
||||||
export interface LogoutRequest {
|
export interface LogoutRequest {
|
||||||
logOutFromAllDevices?: boolean;
|
logOutFromAllDevices?: boolean;
|
||||||
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginRequestSchema: z.Schema<LogoutRequest> = z.object({
|
export const LoginRequestSchema: Schema<LogoutRequest> = object({
|
||||||
logOutFromAllDevices: z.boolean().optional()
|
logOutFromAllDevices: boolean().optional(),
|
||||||
|
token: string()
|
||||||
})
|
})
|
||||||
@ -55,9 +55,14 @@ const login = createAsyncThunk(
|
|||||||
const logout = createAsyncThunk(
|
const logout = createAsyncThunk(
|
||||||
'auth/logout',
|
'auth/logout',
|
||||||
async (logOutFromAllDevices: boolean = false) => {
|
async (logOutFromAllDevices: boolean = false) => {
|
||||||
|
const identity = readIdentity()
|
||||||
|
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date())
|
||||||
|
return
|
||||||
|
|
||||||
const apiRoute = GetApiRoute(ApiRoutes.identityLogout)
|
const apiRoute = GetApiRoute(ApiRoutes.identityLogout)
|
||||||
const response = await postData<LogoutRequest, LogoutResponse>(apiRoute.route, {
|
const response = await postData<LogoutRequest, LogoutResponse>(apiRoute.route, {
|
||||||
logOutFromAllDevices
|
logOutFromAllDevices,
|
||||||
|
token: identity.token
|
||||||
})
|
})
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
namespace Models.LetsEncryptServer.Identity.Logout;
|
namespace Models.LetsEncryptServer.Identity.Logout;
|
||||||
|
|
||||||
public class LogoutRequest : RequestModelBase {
|
public class LogoutRequest : RequestModelBase {
|
||||||
|
public required string Token { get; set; }
|
||||||
public bool LogoutFromAllDevices { get; set; }
|
public bool LogoutFromAllDevices { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user