Refactor API to output error codes instead of literal error texts (#1006)

This commit is contained in:
Leendert de Borst
2025-07-13 17:55:37 +02:00
committed by Leendert de Borst
parent 9999529d60
commit df2ae22a99
5 changed files with 399 additions and 84 deletions

View File

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

View File

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

View File

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

View File

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

View File

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