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)
|
||||
* 31 May, 2024 - V3.0 (Webapi and containerization)
|
||||
* 11 Aug, 2024 - V3.1 (Release)
|
||||
* 11 Sep, 2025 - V3.2 New WebUI with authentication
|
||||
|
||||
## Haproxy configuration
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ namespace MaksIT.LetsEncryptServer {
|
||||
|
||||
public required string CacheFolder { get; set; }
|
||||
public required string AcmeFolder { get; set; }
|
||||
public required string DataFolder { get; set; }
|
||||
|
||||
public required Agent Agent { get; set; }
|
||||
}
|
||||
|
||||
@ -22,28 +22,18 @@ public class IdentityController(
|
||||
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("refresh")]
|
||||
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest requestData) {
|
||||
var result = await _identityService.RefreshTokenAsync(requestData);
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
//[ServiceFilter(typeof(JwtAuthorizationFilter))]
|
||||
//[HttpPost("logout")]
|
||||
//[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
//public async Task<IActionResult> Logout([FromBody] LogoutRequest requetData) {
|
||||
// var jwtTokenDataResult = HttpContext.GetJwtTokenData();
|
||||
// if (!jwtTokenDataResult.IsSuccess || jwtTokenDataResult.Value == null)
|
||||
// return jwtTokenDataResult.ToActionResult();
|
||||
|
||||
// var jwtTokenData = jwtTokenDataResult.Value;
|
||||
|
||||
// var result = await _identityService.Logout(jwtTokenData, requetData);
|
||||
// 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
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
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 List<User> Users { get; set; } = [];
|
||||
|
||||
public Settings() {}
|
||||
public Settings() { }
|
||||
|
||||
public Result<Settings?> Initialize(string pepper) {
|
||||
var userResult = new User("admin")
|
||||
@ -25,15 +25,28 @@ public class Settings : DomainObjectBase {
|
||||
}
|
||||
|
||||
public Result<User?> GetUserByName(string name) {
|
||||
|
||||
var user = Users.FirstOrDefault(x => x.Name == name);
|
||||
|
||||
if (user == null)
|
||||
return Result<User?>.NotFound(null, "User not found.");
|
||||
|
||||
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) {
|
||||
var setPasswordResult = new User(name)
|
||||
.SetPassword(password, pepper);
|
||||
@ -48,6 +61,21 @@ public class Settings : DomainObjectBase {
|
||||
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) {
|
||||
if (Users.Any(x => x.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.");
|
||||
}
|
||||
|
||||
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(
|
||||
Guid id,
|
||||
string name
|
||||
) : DomainDocumentBase<Guid>(id) {
|
||||
) : DomainDocumentBase<Guid>(id) {
|
||||
public string Name { get; private set; } = name;
|
||||
public string Salt { 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(
|
||||
string name
|
||||
@ -19,8 +21,57 @@ public class User(
|
||||
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) {
|
||||
if (!PasswordHasher.TryCreateSaltedHash(password, pepper, out var saltedHash, out var errorMessage))
|
||||
return Result<User?>.InternalServerError(null, errorMessage);
|
||||
@ -31,18 +82,12 @@ public class User(
|
||||
return Result<User?>.Ok(this);
|
||||
}
|
||||
|
||||
// Reset password to a new value (returns this for chaining)
|
||||
public Result<User?> ResetPassword(string newPassword, string pepper) => SetPassword(newPassword, pepper);
|
||||
|
||||
// Change user name
|
||||
public User ChangeName(string newName) {
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
throw new ArgumentException("Name cannot be empty.", nameof(newName));
|
||||
Name = newName;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Validate password
|
||||
/// <summary>
|
||||
/// Validate password
|
||||
/// </summary>
|
||||
/// <param name="password"></param>
|
||||
/// <param name="pepper"></param>
|
||||
/// <returns></returns>
|
||||
public Result ValidatePassword(string password, string pepper) {
|
||||
if (PasswordHasher.TryValidateHash(password, Salt, Hash, pepper, out var isValid, out var errorMessage)) {
|
||||
if (isValid)
|
||||
@ -54,11 +99,72 @@ public class User(
|
||||
return Result<User?>.InternalServerError(null, errorMessage);
|
||||
}
|
||||
|
||||
// For persistence
|
||||
public User SeltSaltedHash(string salt, string hash) {
|
||||
Salt = salt;
|
||||
Hash = hash;
|
||||
/// <summary>
|
||||
/// Reset password to a new value (returns this for chaining)
|
||||
/// </summary>
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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 required bool Init { 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 {
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Salt { get; set; }
|
||||
public required string Hash { get; set; }
|
||||
namespace MaksIT.LetsEncryptServer.Dto;
|
||||
|
||||
public class UserDto : DtoDocumentBase<Guid> {
|
||||
public required string Name { get; set; } = string.Empty;
|
||||
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;
|
||||
|
||||
try {
|
||||
var pdfBytesTask = _httpClient.GetByteArrayAsync(termsOfServiceUrl);
|
||||
pdfBytesTask.Wait();
|
||||
var pdfBytes = pdfBytesTask.Result;
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
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 or convert Terms of Service PDF");
|
||||
return Result<string?>.InternalServerError(null, $"Failed to download or convert Terms of Service PDF: {ex.Message}");
|
||||
_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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ 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;
|
||||
|
||||
namespace MaksIT.LetsEncryptServer.Services;
|
||||
@ -12,8 +13,8 @@ namespace MaksIT.LetsEncryptServer.Services;
|
||||
public interface IIdentityService {
|
||||
#region Login/Refresh/Logout
|
||||
Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData);
|
||||
//Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData);
|
||||
//Task<Result> Logout(JwtTokenData jwtTokenData, LogoutRequest requestData);
|
||||
Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData);
|
||||
Task<Result> Logout(LogoutRequest requestData);
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -64,45 +65,132 @@ public class IdentityService(
|
||||
|
||||
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 {
|
||||
TokenType = "Bearer",
|
||||
Token = token,
|
||||
TokenType = tokenDomain.TokenType,
|
||||
Token = tokenDomain.Token,
|
||||
ExpiresAt = claims.ExpiresAt.Value,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshTokenExpiresAt = claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration)
|
||||
RefreshToken = tokenDomain.RefreshToken,
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||
};
|
||||
|
||||
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) {
|
||||
// return await HandleTokenResponseAsync(() =>
|
||||
// _identityDomainService.RefreshTokenAsync(requestData.RefreshToken));
|
||||
//}
|
||||
if (tokenDomain == null)
|
||||
return Result<LoginResponse?>.Unauthorized(null, "Invalid refresh token.");
|
||||
|
||||
//private static async Task<Result<LoginResponse?>> HandleTokenResponseAsync(Func<Task<Result<JwtToken?>>> tokenOperation) {
|
||||
// var jwtTokenResult = await tokenOperation();
|
||||
// if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null)
|
||||
// return jwtTokenResult.ToResultOfType<LoginResponse?>(_ => null);
|
||||
// Token is still valid
|
||||
if (DateTime.UtcNow <= tokenDomain.ExpiresAt) {
|
||||
user.SetLastLogin();
|
||||
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 {
|
||||
// TokenType = jwtToken.TokenType,
|
||||
// Token = jwtToken.Token,
|
||||
// ExpiresAt = jwtToken.ExpiresAt,
|
||||
// RefreshToken = jwtToken.RefreshToken,
|
||||
// RefreshTokenExpiresAt = jwtToken.RefreshTokenExpiresAt
|
||||
// });
|
||||
//}
|
||||
return Result<LoginResponse?>.Ok(new LoginResponse {
|
||||
TokenType = tokenDomain.TokenType,
|
||||
Token = tokenDomain.Token,
|
||||
ExpiresAt = tokenDomain.ExpiresAt,
|
||||
RefreshToken = tokenDomain.RefreshToken,
|
||||
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt
|
||||
});
|
||||
}
|
||||
|
||||
//public async Task<Result> Logout(JwtTokenData jwtTokenData, LogoutRequest requestData) {
|
||||
// var logoutResult = await _identityDomainService.LogoutAsync(jwtTokenData.Username, jwtTokenData.Token, requestData.LogoutFromAllDevices);
|
||||
// return logoutResult;
|
||||
//}
|
||||
// Refresh token expired
|
||||
if (DateTime.UtcNow > tokenDomain.RefreshTokenExpiresAt) {
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ using System.Threading.Tasks;
|
||||
namespace MaksIT.LetsEncryptServer.Services;
|
||||
|
||||
public interface ISettingsService {
|
||||
Task<Result<Settings>> LoadAsync();
|
||||
Task<Result<Settings?>> LoadAsync();
|
||||
Task<Result> SaveAsync(Settings settings);
|
||||
}
|
||||
|
||||
@ -31,24 +31,34 @@ public class SettingsService : ISettingsService, IDisposable {
|
||||
|
||||
#region Internal I/O
|
||||
|
||||
private async Task<Result<Settings>> LoadInternalAsync() {
|
||||
private async Task<Result<Settings?>> LoadInternalAsync() {
|
||||
try {
|
||||
if (!File.Exists(_settingsPath))
|
||||
return Result<Settings>.Ok(new Settings());
|
||||
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.");
|
||||
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.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);
|
||||
} catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error loading settings file.");
|
||||
return Result<Settings>.InternalServerError(new Settings(), ex.Message);
|
||||
return Result<Settings?>.Ok(settings);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
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 {
|
||||
Init = settings.Init,
|
||||
Users = [.. settings.Users.Select(u => new UserDto {
|
||||
Id = u.Id.ToString(),
|
||||
Id = u.Id,
|
||||
Name = u.Name,
|
||||
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());
|
||||
_logger.LogInformation("Settings file saved.");
|
||||
return Result.Ok();
|
||||
} catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error saving settings file.");
|
||||
return Result.InternalServerError(ex.Message);
|
||||
}
|
||||
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() {
|
||||
public async Task<Result<Settings?>> LoadAsync() {
|
||||
return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync());
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
|
||||
"CacheFolder": "/cache",
|
||||
"AcmeFolder": "/acme",
|
||||
"DataFolder": "/data",
|
||||
|
||||
"Agent": {
|
||||
"AgentHostname": "",
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { boolean, object, Schema, string } from 'zod'
|
||||
|
||||
export interface LogoutRequest {
|
||||
logOutFromAllDevices?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const LoginRequestSchema: z.Schema<LogoutRequest> = z.object({
|
||||
logOutFromAllDevices: z.boolean().optional()
|
||||
export const LoginRequestSchema: Schema<LogoutRequest> = object({
|
||||
logOutFromAllDevices: boolean().optional(),
|
||||
token: string()
|
||||
})
|
||||
@ -55,9 +55,14 @@ const login = createAsyncThunk(
|
||||
const logout = createAsyncThunk(
|
||||
'auth/logout',
|
||||
async (logOutFromAllDevices: boolean = false) => {
|
||||
const identity = readIdentity()
|
||||
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date())
|
||||
return
|
||||
|
||||
const apiRoute = GetApiRoute(ApiRoutes.identityLogout)
|
||||
const response = await postData<LogoutRequest, LogoutResponse>(apiRoute.route, {
|
||||
logOutFromAllDevices
|
||||
logOutFromAllDevices,
|
||||
token: identity.token
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
@ -4,5 +4,6 @@
|
||||
namespace Models.LetsEncryptServer.Identity.Logout;
|
||||
|
||||
public class LogoutRequest : RequestModelBase {
|
||||
public required string Token { get; set; }
|
||||
public bool LogoutFromAllDevices { get; set; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user