(feature): login, refresh and logout

This commit is contained in:
Maksym Sadovnychyy 2025-11-09 15:18:08 +01:00
parent c6dbf6d195
commit 5d697fbcef
16 changed files with 478 additions and 116 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

@ -3,4 +3,5 @@
public class SettingsDto {
public required bool Init { get; set; }
public required List<UserDto> Users { get; set; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@
"CacheFolder": "/cache",
"AcmeFolder": "/acme",
"DataFolder": "/data",
"Agent": {
"AgentHostname": "",

View File

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

View File

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

View File

@ -4,5 +4,6 @@
namespace Models.LetsEncryptServer.Identity.Logout;
public class LogoutRequest : RequestModelBase {
public required string Token { get; set; }
public bool LogoutFromAllDevices { get; set; }
}