mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 08:17:57 -04:00
Refactor API to output error codes instead of literal error texts (#1006)
This commit is contained in:
committed by
Leendert de Borst
parent
9999529d60
commit
df2ae22a99
@@ -48,31 +48,6 @@ using SecureRemotePassword;
|
||||
[ApiVersion("1")]
|
||||
public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Error message for invalid username or password.
|
||||
/// </summary>
|
||||
private static readonly string[] InvalidUsernameOrPasswordError = ["Invalid username or password. Please try again."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for invalid 2-factor authentication code.
|
||||
/// </summary>
|
||||
private static readonly string[] Invalid2FaCode = ["Invalid authenticator code."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for invalid 2-factor authentication recovery code.
|
||||
/// </summary>
|
||||
private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for too many failed login attempts.
|
||||
/// </summary>
|
||||
private static readonly string[] AccountLocked = ["You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for if user is (manually) blocked by admin.
|
||||
/// </summary>
|
||||
private static readonly string[] AccountBlocked = ["Your account has been disabled. If you believe this is a mistake, please contact support."];
|
||||
|
||||
/// <summary>
|
||||
/// Semaphore to prevent concurrent access to the database when generating new tokens for a user.
|
||||
/// </summary>
|
||||
@@ -158,14 +133,14 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (await userManager.IsLockedOutAsync(user))
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.TwoFactorAuthentication, AuthFailureReason.AccountLocked);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.ACCOUNT_LOCKED, 400));
|
||||
}
|
||||
|
||||
// Check if the account is blocked.
|
||||
if (user.Blocked)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.AccountBlocked);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(AccountBlocked, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 400));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current salt and verifier.
|
||||
@@ -228,7 +203,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (user == null || serverSession == null)
|
||||
{
|
||||
// Expected variables are not set, return generic error.
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400));
|
||||
}
|
||||
|
||||
// Verify 2-factor code.
|
||||
@@ -239,7 +214,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
await userManager.AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.TwoFactorAuthentication, AuthFailureReason.InvalidTwoFactorCode);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(Invalid2FaCode, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.INVALID_AUTHENTICATOR_CODE, 400));
|
||||
}
|
||||
|
||||
// Validation of 2-FA token is successful, user is authenticated.
|
||||
@@ -271,7 +246,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (user == null || serverSession == null)
|
||||
{
|
||||
// Expected variables are not set, return generic error.
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400));
|
||||
}
|
||||
|
||||
// Sanitize recovery code.
|
||||
@@ -286,7 +261,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
await userManager.AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.TwoFactorAuthentication, AuthFailureReason.InvalidRecoveryCode);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidRecoveryCode, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.INVALID_RECOVERY_CODE, 400));
|
||||
}
|
||||
|
||||
// Recovery code is valid, user is authenticated.
|
||||
@@ -313,26 +288,26 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
// If the token is not provided, return bad request.
|
||||
if (string.IsNullOrWhiteSpace(tokenModel.RefreshToken))
|
||||
{
|
||||
return BadRequest("Refresh token is required.");
|
||||
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.REFRESH_TOKEN_REQUIRED, 400));
|
||||
}
|
||||
|
||||
var principal = GetPrincipalFromToken(tokenModel.Token);
|
||||
if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null)
|
||||
{
|
||||
return Unauthorized("User not found (name-1)");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND_IN_TOKEN, 401));
|
||||
}
|
||||
|
||||
var user = await userManager.FindByIdAsync(principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty);
|
||||
if (user == null)
|
||||
{
|
||||
return Unauthorized("User not found (name-2)");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND_IN_DATABASE, 401));
|
||||
}
|
||||
|
||||
// Check if the account is blocked.
|
||||
if (user.Blocked)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.AccountBlocked);
|
||||
return Unauthorized("Account blocked");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 401));
|
||||
}
|
||||
|
||||
// Generate new tokens for the user.
|
||||
@@ -340,7 +315,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (token == null)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken);
|
||||
return Unauthorized("Invalid refresh token");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.INVALID_REFRESH_TOKEN, 401));
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
@@ -362,19 +337,19 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
// If the token is not provided, return bad request.
|
||||
if (string.IsNullOrWhiteSpace(model.RefreshToken))
|
||||
{
|
||||
return BadRequest("Refresh token is required.");
|
||||
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.REFRESH_TOKEN_REQUIRED, 400));
|
||||
}
|
||||
|
||||
var principal = GetPrincipalFromToken(model.Token);
|
||||
if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null)
|
||||
{
|
||||
return Unauthorized("User not found (name-1)");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND_IN_TOKEN, 401));
|
||||
}
|
||||
|
||||
var user = await userManager.FindByIdAsync(principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty);
|
||||
if (user == null)
|
||||
{
|
||||
return Unauthorized("User not found (name-2)");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND_IN_DATABASE, 401));
|
||||
}
|
||||
|
||||
// Check if the refresh token is valid.
|
||||
@@ -382,7 +357,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (!providedTokenExists)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Logout, AuthFailureReason.InvalidRefreshToken);
|
||||
return Unauthorized("Invalid refresh token");
|
||||
return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.INVALID_REFRESH_TOKEN, 401));
|
||||
}
|
||||
|
||||
// Remove the provided refresh token and any other existing refresh tokens that are issued to the current device ID.
|
||||
@@ -393,7 +368,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.Logout);
|
||||
return Ok("Refresh token revoked successfully");
|
||||
return Ok(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.REFRESH_TOKEN_REVOKED_SUCCESSFULLY, 200));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -407,14 +382,14 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
// Check if public registration is disabled in the configuration.
|
||||
if (!config.PublicRegistrationEnabled)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(["New account registration is currently disabled on this server. Please contact the administrator."], 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.PUBLIC_REGISTRATION_DISABLED, 400));
|
||||
}
|
||||
|
||||
// Validate the username.
|
||||
var (isValid, errorMessage) = ValidateUsername(model.Username);
|
||||
if (!isValid)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create([errorMessage], 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(GetUsernameValidationErrorCode(errorMessage), 400));
|
||||
}
|
||||
|
||||
var user = new AliasVaultUser
|
||||
@@ -471,7 +446,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
var user = await userManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(ServerValidationErrorResponse.Create("User not found.", 404));
|
||||
return NotFound(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 404));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current salt and verifier.
|
||||
@@ -497,7 +472,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.Username))
|
||||
{
|
||||
return BadRequest("Username is required.");
|
||||
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USERNAME_REQUIRED, 400));
|
||||
}
|
||||
|
||||
var normalizedUsername = NormalizeUsername(model.Username);
|
||||
@@ -505,7 +480,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
return BadRequest("Username is already in use.");
|
||||
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USERNAME_ALREADY_IN_USE, 400));
|
||||
}
|
||||
|
||||
// Validate the username
|
||||
@@ -513,10 +488,10 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
return BadRequest(errorMessage);
|
||||
return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(GetUsernameValidationErrorCode(errorMessage), 400));
|
||||
}
|
||||
|
||||
return Ok("Username is available.");
|
||||
return Ok(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USERNAME_AVAILABLE, 200));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -531,13 +506,13 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
var user = await userManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(ServerValidationErrorResponse.Create("User not found.", 404));
|
||||
return NotFound(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 404));
|
||||
}
|
||||
|
||||
// Verify the username matches the current user.
|
||||
if (user.UserName != model.Username)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create("Username does not match the current user.", 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current salt and verifier.
|
||||
@@ -568,13 +543,13 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
var user = await userManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(ServerValidationErrorResponse.Create("User not found.", 404));
|
||||
return NotFound(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 404));
|
||||
}
|
||||
|
||||
// Verify the username matches the current user.
|
||||
if (user.UserName != model.Username)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create("Username does not match the current user.", 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400));
|
||||
}
|
||||
|
||||
// Validate the SRP session (actual password check).
|
||||
@@ -582,7 +557,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (serverSession is null)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.AccountDeletion, AuthFailureReason.InvalidPassword);
|
||||
return BadRequest(ServerValidationErrorResponse.Create("The provided password does not match your current password.", 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.PASSWORD_MISMATCH, 400));
|
||||
}
|
||||
|
||||
// Log the successful account deletion.
|
||||
@@ -593,7 +568,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
context.AliasVaultUsers.Remove(user);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "Account successfully deleted." });
|
||||
return Ok(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_SUCCESSFULLY_DELETED, 200));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -619,22 +594,22 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return (false, "Username cannot be empty or whitespace.");
|
||||
return (false, ApiErrorCode.USERNAME_EMPTY_OR_WHITESPACE.ToCode());
|
||||
}
|
||||
|
||||
if (username.Length < minimumUsernameLength)
|
||||
{
|
||||
return (false, $"Username too short: must be at least {minimumUsernameLength} characters long.");
|
||||
return (false, ApiErrorCode.USERNAME_TOO_SHORT.ToCode());
|
||||
}
|
||||
|
||||
if (username.Length > maximumUsernameLength)
|
||||
{
|
||||
return (false, $"Username too long: cannot be longer than {maximumUsernameLength} characters.");
|
||||
return (false, ApiErrorCode.USERNAME_TOO_LONG.ToCode());
|
||||
}
|
||||
|
||||
if (string.Equals(username, adminUsername, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (false, "Username 'admin' is not allowed.");
|
||||
return (false, ApiErrorCode.USERNAME_ADMIN_NOT_ALLOWED.ToCode());
|
||||
}
|
||||
|
||||
// Check if it's a valid email address
|
||||
@@ -647,19 +622,38 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, $"'{username}' is not a valid email address.");
|
||||
return (false, ApiErrorCode.USERNAME_INVALID_EMAIL.ToCode());
|
||||
}
|
||||
}
|
||||
|
||||
// If it's not an email, check if it only contains letters and digits
|
||||
if (!username.All(char.IsLetterOrDigit))
|
||||
{
|
||||
return (false, $"Username '{username}' is invalid, can only contain letters or digits.");
|
||||
return (false, ApiErrorCode.USERNAME_INVALID_CHARACTERS.ToCode());
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appropriate error code for a username validation error message.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">The validation error message.</param>
|
||||
/// <returns>Corresponding ApiErrorCode.</returns>
|
||||
private static ApiErrorCode GetUsernameValidationErrorCode(string errorMessage)
|
||||
{
|
||||
return errorMessage switch
|
||||
{
|
||||
var msg when msg.Contains("empty or whitespace") => ApiErrorCode.USERNAME_EMPTY_OR_WHITESPACE,
|
||||
var msg when msg.Contains("too short") => ApiErrorCode.USERNAME_TOO_SHORT,
|
||||
var msg when msg.Contains("too long") => ApiErrorCode.USERNAME_TOO_LONG,
|
||||
var msg when msg.Contains("admin") => ApiErrorCode.USERNAME_ADMIN_NOT_ALLOWED,
|
||||
var msg when msg.Contains("valid email") => ApiErrorCode.USERNAME_INVALID_EMAIL,
|
||||
var msg when msg.Contains("invalid") => ApiErrorCode.USERNAME_INVALID_CHARACTERS,
|
||||
_ => ApiErrorCode.UNKNOWN_ERROR,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
|
||||
/// with a specific device for a specific user.
|
||||
@@ -750,21 +744,21 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
if (user == null)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.InvalidUsername);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)));
|
||||
return (null, null, BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400)));
|
||||
}
|
||||
|
||||
// Check if the account is locked out.
|
||||
if (await userManager.IsLockedOutAsync(user))
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TwoFactorAuthentication, AuthFailureReason.AccountLocked);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400)));
|
||||
return (null, null, BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.ACCOUNT_LOCKED, 400)));
|
||||
}
|
||||
|
||||
// Check if the account is blocked.
|
||||
if (user.Blocked)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.AccountBlocked);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountBlocked, 400)));
|
||||
return (null, null, BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 400)));
|
||||
}
|
||||
|
||||
// Validate the SRP session (actual password check).
|
||||
@@ -775,7 +769,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
await userManager.AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Login, AuthFailureReason.InvalidPassword);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)));
|
||||
return (null, null, BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400)));
|
||||
}
|
||||
|
||||
return (user, serverSession, null);
|
||||
|
||||
@@ -39,21 +39,6 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
[ApiVersion("1")]
|
||||
public class VaultController(ILogger<VaultController> logger, IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache, Config config) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Error message for providing an invalid current password (during password change).
|
||||
/// </summary>
|
||||
private static readonly string[] InvalidCurrentPassword = ["The current password provided is invalid. Please try again."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for providing an invalid username.
|
||||
/// </summary>
|
||||
private static readonly string[] InvalidUsername = ["The currently logged on user is not the owner of the vault being saved. Please save your changes."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for providing an older vault revision number.
|
||||
/// </summary>
|
||||
private static readonly string[] OlderVaultRevisionNumber = ["The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."];
|
||||
|
||||
/// <summary>
|
||||
/// Default retention policy for vaults.
|
||||
/// </summary>
|
||||
@@ -225,7 +210,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
|
||||
// that is being used to update the vault (e.g. if working with multiple tabs).
|
||||
if (user.UserName != model.Username)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsername, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current encryption settings.
|
||||
@@ -234,7 +219,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
|
||||
// Reject vaults with a version that is lower than the last vault version.
|
||||
if (VersionHelper.IsVersionOlder(model.Version, latestVault.Version))
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(OlderVaultRevisionNumber, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_NOT_UP_TO_DATE, 400));
|
||||
}
|
||||
|
||||
// Calculate the new revision number for the vault.
|
||||
@@ -313,7 +298,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
|
||||
// that is being used to update the vault (e.g. if working with multiple tabs).
|
||||
if (model.Username != user.UserName)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsername, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400));
|
||||
}
|
||||
|
||||
// Validate the SRP session (actual password check).
|
||||
@@ -324,7 +309,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
|
||||
await GetUserManager().AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.PasswordChange, AuthFailureReason.InvalidPassword);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidCurrentPassword, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.PASSWORD_MISMATCH, 400));
|
||||
}
|
||||
|
||||
// Check if the provided revision number is equal to the latest revision number.
|
||||
@@ -332,7 +317,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
|
||||
var latestVault = user.Vaults.OrderByDescending(x => x.RevisionNumber).First();
|
||||
if (VersionHelper.IsVersionOlder(model.Version, latestVault.Version))
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(OlderVaultRevisionNumber, 400));
|
||||
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_NOT_UP_TO_DATE, 400));
|
||||
}
|
||||
|
||||
// Calculate the new revision number for the vault.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
namespace AliasVault.Client.Utilities;
|
||||
|
||||
using AliasVault.Shared.Models.WebApi;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for parsing API responses.
|
||||
@@ -19,6 +20,7 @@ public static class ApiResponseUtility
|
||||
/// </summary>
|
||||
/// <param name="responseContent">Response content.</param>
|
||||
/// <returns>List of errors if something went wrong.</returns>
|
||||
[Obsolete("Use ParseErrorResponse(string responseContent, IStringLocalizer localizer) instead for localized error messages.")]
|
||||
public static List<string> ParseErrorResponse(string responseContent)
|
||||
{
|
||||
var returnErrors = new List<string>();
|
||||
@@ -34,4 +36,81 @@ public static class ApiResponseUtility
|
||||
|
||||
return returnErrors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the response content and returns localized error messages.
|
||||
/// </summary>
|
||||
/// <param name="responseContent">Response content.</param>
|
||||
/// <param name="localizer">String localizer for API errors.</param>
|
||||
/// <returns>List of localized error messages.</returns>
|
||||
public static List<string> ParseErrorResponse(string responseContent, IStringLocalizer localizer)
|
||||
{
|
||||
var returnErrors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var errorResponse = System.Text.Json.JsonSerializer.Deserialize<ServerValidationErrorResponse>(responseContent);
|
||||
if (errorResponse is not null)
|
||||
{
|
||||
foreach (var error in errorResponse.Errors)
|
||||
{
|
||||
foreach (var errorCode in error.Value)
|
||||
{
|
||||
// Try to get localized message for the error code
|
||||
var localizedMessage = localizer[errorCode];
|
||||
|
||||
// If localization returns the key itself, it means no translation was found
|
||||
// In this case, use the error code as fallback or a generic message
|
||||
if (localizedMessage.ResourceNotFound)
|
||||
{
|
||||
// Try to get a generic error message
|
||||
var genericError = localizer["UnknownError"];
|
||||
returnErrors.Add(genericError.ResourceNotFound ? "An error occurred. Please try again." : genericError.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
returnErrors.Add(localizedMessage.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If parsing fails, return a generic error message
|
||||
var genericError = localizer["UnknownError"];
|
||||
returnErrors.Add(genericError.ResourceNotFound ? "An error occurred. Please try again." : genericError.Value);
|
||||
}
|
||||
|
||||
return returnErrors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the response content for non-validation errors (like ApiErrorResponse).
|
||||
/// </summary>
|
||||
/// <param name="responseContent">Response content.</param>
|
||||
/// <param name="localizer">String localizer for API errors.</param>
|
||||
/// <returns>Localized error message.</returns>
|
||||
public static string ParseSingleErrorResponse(string responseContent, IStringLocalizer localizer)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to parse as ApiErrorResponse first
|
||||
var apiErrorResponse = System.Text.Json.JsonSerializer.Deserialize<ApiErrorResponse>(responseContent);
|
||||
if (apiErrorResponse is not null && !string.IsNullOrEmpty(apiErrorResponse.Code))
|
||||
{
|
||||
var localizedMessage = localizer[apiErrorResponse.Code];
|
||||
return localizedMessage.ResourceNotFound ? apiErrorResponse.Code : localizedMessage.Value;
|
||||
}
|
||||
|
||||
// Fall back to ServerValidationErrorResponse
|
||||
var errors = ParseErrorResponse(responseContent, localizer);
|
||||
return errors.Count > 0 ? errors[0] : "An error occurred. Please try again.";
|
||||
}
|
||||
catch
|
||||
{
|
||||
var genericError = localizer["UnknownError"];
|
||||
return genericError.ResourceNotFound ? "An error occurred. Please try again." : genericError.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="ApiErrorCode.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Shared.Models.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Enumeration of error codes returned by the API.
|
||||
/// These codes are used by clients for localization and proper error handling.
|
||||
/// Using explicit string keys ensures backward compatibility when adding new error codes.
|
||||
/// </summary>
|
||||
public enum ApiErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Refresh token is required but was not provided.
|
||||
/// </summary>
|
||||
REFRESH_TOKEN_REQUIRED,
|
||||
|
||||
/// <summary>
|
||||
/// User not found in the provided token.
|
||||
/// </summary>
|
||||
USER_NOT_FOUND_IN_TOKEN,
|
||||
|
||||
/// <summary>
|
||||
/// User not found in the database.
|
||||
/// </summary>
|
||||
USER_NOT_FOUND_IN_DATABASE,
|
||||
|
||||
/// <summary>
|
||||
/// User account is locked.
|
||||
/// </summary>
|
||||
ACCOUNT_LOCKED,
|
||||
|
||||
/// <summary>
|
||||
/// User account is blocked.
|
||||
/// </summary>
|
||||
ACCOUNT_BLOCKED,
|
||||
|
||||
/// <summary>
|
||||
/// The provided refresh token is invalid.
|
||||
/// </summary>
|
||||
INVALID_REFRESH_TOKEN,
|
||||
|
||||
/// <summary>
|
||||
/// Refresh token was successfully revoked.
|
||||
/// </summary>
|
||||
REFRESH_TOKEN_REVOKED_SUCCESSFULLY,
|
||||
|
||||
/// <summary>
|
||||
/// Public registration is disabled on this server.
|
||||
/// </summary>
|
||||
PUBLIC_REGISTRATION_DISABLED,
|
||||
|
||||
/// <summary>
|
||||
/// User not found.
|
||||
/// </summary>
|
||||
USER_NOT_FOUND,
|
||||
|
||||
/// <summary>
|
||||
/// Username is required but was not provided.
|
||||
/// </summary>
|
||||
USERNAME_REQUIRED,
|
||||
|
||||
/// <summary>
|
||||
/// Username is already in use.
|
||||
/// </summary>
|
||||
USERNAME_ALREADY_IN_USE,
|
||||
|
||||
/// <summary>
|
||||
/// Username is available.
|
||||
/// </summary>
|
||||
USERNAME_AVAILABLE,
|
||||
|
||||
/// <summary>
|
||||
/// Username does not match.
|
||||
/// </summary>
|
||||
USERNAME_MISMATCH,
|
||||
|
||||
/// <summary>
|
||||
/// Password does not match.
|
||||
/// </summary>
|
||||
PASSWORD_MISMATCH,
|
||||
|
||||
/// <summary>
|
||||
/// Account was successfully deleted.
|
||||
/// </summary>
|
||||
ACCOUNT_SUCCESSFULLY_DELETED,
|
||||
|
||||
/// <summary>
|
||||
/// Username cannot be empty or whitespace.
|
||||
/// </summary>
|
||||
USERNAME_EMPTY_OR_WHITESPACE,
|
||||
|
||||
/// <summary>
|
||||
/// Username is too short.
|
||||
/// </summary>
|
||||
USERNAME_TOO_SHORT,
|
||||
|
||||
/// <summary>
|
||||
/// Username is too long.
|
||||
/// </summary>
|
||||
USERNAME_TOO_LONG,
|
||||
|
||||
/// <summary>
|
||||
/// Username 'admin' is not allowed.
|
||||
/// </summary>
|
||||
USERNAME_ADMIN_NOT_ALLOWED,
|
||||
|
||||
/// <summary>
|
||||
/// Username is not a valid email address.
|
||||
/// </summary>
|
||||
USERNAME_INVALID_EMAIL,
|
||||
|
||||
/// <summary>
|
||||
/// Username contains invalid characters.
|
||||
/// </summary>
|
||||
USERNAME_INVALID_CHARACTERS,
|
||||
|
||||
/// <summary>
|
||||
/// There are pending database migrations.
|
||||
/// </summary>
|
||||
PENDING_MIGRATIONS,
|
||||
|
||||
/// <summary>
|
||||
/// System is OK.
|
||||
/// </summary>
|
||||
SYSTEM_OK,
|
||||
|
||||
/// <summary>
|
||||
/// Internal server error occurred.
|
||||
/// </summary>
|
||||
INTERNAL_SERVER_ERROR,
|
||||
|
||||
/// <summary>
|
||||
/// Generic vault error.
|
||||
/// </summary>
|
||||
VAULT_ERROR,
|
||||
|
||||
/// <summary>
|
||||
/// Unknown error occurred.
|
||||
/// </summary>
|
||||
UNKNOWN_ERROR,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid authenticator code provided.
|
||||
/// </summary>
|
||||
INVALID_AUTHENTICATOR_CODE,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid recovery code provided.
|
||||
/// </summary>
|
||||
INVALID_RECOVERY_CODE,
|
||||
|
||||
/// <summary>
|
||||
/// Vault is not up-to-date and requires synchronization.
|
||||
/// </summary>
|
||||
VAULT_NOT_UP_TO_DATE,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="ApiErrorCodeHelper.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Shared.Models.WebApi;
|
||||
|
||||
using AliasVault.Shared.Models.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for working with API error codes.
|
||||
/// </summary>
|
||||
public static class ApiErrorCodeHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an ApiErrorCode enum value to its string representation.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The error code to convert.</param>
|
||||
/// <returns>String representation of the error code.</returns>
|
||||
public static string ToCode(this ApiErrorCode errorCode)
|
||||
{
|
||||
return errorCode.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ApiErrorResponse with the specified error code and status.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The error code.</param>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <param name="details">Optional additional details.</param>
|
||||
/// <returns>ApiErrorResponse object.</returns>
|
||||
public static ApiErrorResponse CreateErrorResponse(ApiErrorCode errorCode, int statusCode, object? details = null)
|
||||
{
|
||||
return new ApiErrorResponse
|
||||
{
|
||||
Code = errorCode.ToCode(),
|
||||
Message = errorCode.ToCode(), // Clients will replace this with localized message
|
||||
StatusCode = statusCode,
|
||||
Details = details ?? new { },
|
||||
Timestamp = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ServerValidationErrorResponse with error codes instead of plain text.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The error code.</param>
|
||||
/// <param name="status">The HTTP status code.</param>
|
||||
/// <returns>ServerValidationErrorResponse object.</returns>
|
||||
public static ServerValidationErrorResponse CreateValidationErrorResponse(ApiErrorCode errorCode, int status)
|
||||
{
|
||||
var code = errorCode.ToCode();
|
||||
var errors = new Dictionary<string, string[]>
|
||||
{
|
||||
{ code, [code] },
|
||||
};
|
||||
|
||||
return new ServerValidationErrorResponse
|
||||
{
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
Title = code,
|
||||
Errors = errors,
|
||||
Status = status,
|
||||
TraceId = Guid.NewGuid().ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ServerValidationErrorResponse with multiple error codes.
|
||||
/// </summary>
|
||||
/// <param name="errorCodes">Array of error codes.</param>
|
||||
/// <param name="status">The HTTP status code.</param>
|
||||
/// <returns>ServerValidationErrorResponse object.</returns>
|
||||
public static ServerValidationErrorResponse CreateValidationErrorResponse(ApiErrorCode[] errorCodes, int status)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
foreach (var errorCode in errorCodes)
|
||||
{
|
||||
var code = errorCode.ToCode();
|
||||
errors.Add(code, new[] { code });
|
||||
}
|
||||
|
||||
var firstCode = errorCodes.Length > 0 ? errorCodes[0].ToCode() : ApiErrorCode.UNKNOWN_ERROR.ToCode();
|
||||
|
||||
return new ServerValidationErrorResponse
|
||||
{
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
Title = firstCode,
|
||||
Errors = errors,
|
||||
Status = status,
|
||||
TraceId = Guid.NewGuid().ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user