From df2ae22a997390d2a241f0252a21ac11cd3b2aa1 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 13 Jul 2025 17:55:37 +0200 Subject: [PATCH] Refactor API to output error codes instead of literal error texts (#1006) --- .../Controllers/AuthController.cs | 122 +++++++------ .../Controllers/VaultController.cs | 25 +-- .../Utilities/ApiResponseUtility.cs | 79 +++++++++ .../Models/Enums/ApiErrorCode.cs | 161 ++++++++++++++++++ .../Models/WebApi/ApiErrorCodeHelper.cs | 96 +++++++++++ 5 files changed, 399 insertions(+), 84 deletions(-) create mode 100644 apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/ApiErrorCodeHelper.cs diff --git a/apps/server/AliasVault.Api/Controllers/AuthController.cs b/apps/server/AliasVault.Api/Controllers/AuthController.cs index 6dc877379..fc840740b 100644 --- a/apps/server/AliasVault.Api/Controllers/AuthController.cs +++ b/apps/server/AliasVault.Api/Controllers/AuthController.cs @@ -48,31 +48,6 @@ using SecureRemotePassword; [ApiVersion("1")] public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserManager userManager, SignInManager signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase { - /// - /// Error message for invalid username or password. - /// - private static readonly string[] InvalidUsernameOrPasswordError = ["Invalid username or password. Please try again."]; - - /// - /// Error message for invalid 2-factor authentication code. - /// - private static readonly string[] Invalid2FaCode = ["Invalid authenticator code."]; - - /// - /// Error message for invalid 2-factor authentication recovery code. - /// - private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."]; - - /// - /// Error message for too many failed login attempts. - /// - 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."]; - - /// - /// Error message for if user is (manually) blocked by admin. - /// - private static readonly string[] AccountBlocked = ["Your account has been disabled. If you believe this is a mistake, please contact support."]; - /// /// Semaphore to prevent concurrent access to the database when generating new tokens for a user. /// @@ -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)); } /// @@ -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)); } /// @@ -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)); } /// @@ -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); } + /// + /// Gets the appropriate error code for a username validation error message. + /// + /// The validation error message. + /// Corresponding ApiErrorCode. + 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, + }; + } + /// /// 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); diff --git a/apps/server/AliasVault.Api/Controllers/VaultController.cs b/apps/server/AliasVault.Api/Controllers/VaultController.cs index 721a7fd2f..ed7320946 100644 --- a/apps/server/AliasVault.Api/Controllers/VaultController.cs +++ b/apps/server/AliasVault.Api/Controllers/VaultController.cs @@ -39,21 +39,6 @@ using Microsoft.Extensions.Caching.Memory; [ApiVersion("1")] public class VaultController(ILogger logger, IAliasServerDbContextFactory dbContextFactory, UserManager userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache, Config config) : AuthenticatedRequestController(userManager) { - /// - /// Error message for providing an invalid current password (during password change). - /// - private static readonly string[] InvalidCurrentPassword = ["The current password provided is invalid. Please try again."]; - - /// - /// Error message for providing an invalid username. - /// - private static readonly string[] InvalidUsername = ["The currently logged on user is not the owner of the vault being saved. Please save your changes."]; - - /// - /// Error message for providing an older vault revision number. - /// - private static readonly string[] OlderVaultRevisionNumber = ["The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."]; - /// /// Default retention policy for vaults. /// @@ -225,7 +210,7 @@ public class VaultController(ILogger 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 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 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 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 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. diff --git a/apps/server/AliasVault.Client/Utilities/ApiResponseUtility.cs b/apps/server/AliasVault.Client/Utilities/ApiResponseUtility.cs index 91184d02b..01c671983 100644 --- a/apps/server/AliasVault.Client/Utilities/ApiResponseUtility.cs +++ b/apps/server/AliasVault.Client/Utilities/ApiResponseUtility.cs @@ -8,6 +8,7 @@ namespace AliasVault.Client.Utilities; using AliasVault.Shared.Models.WebApi; +using Microsoft.Extensions.Localization; /// /// Helper methods for parsing API responses. @@ -19,6 +20,7 @@ public static class ApiResponseUtility /// /// Response content. /// List of errors if something went wrong. + [Obsolete("Use ParseErrorResponse(string responseContent, IStringLocalizer localizer) instead for localized error messages.")] public static List ParseErrorResponse(string responseContent) { var returnErrors = new List(); @@ -34,4 +36,81 @@ public static class ApiResponseUtility return returnErrors; } + + /// + /// Parses the response content and returns localized error messages. + /// + /// Response content. + /// String localizer for API errors. + /// List of localized error messages. + public static List ParseErrorResponse(string responseContent, IStringLocalizer localizer) + { + var returnErrors = new List(); + + try + { + var errorResponse = System.Text.Json.JsonSerializer.Deserialize(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; + } + + /// + /// Parses the response content for non-validation errors (like ApiErrorResponse). + /// + /// Response content. + /// String localizer for API errors. + /// Localized error message. + public static string ParseSingleErrorResponse(string responseContent, IStringLocalizer localizer) + { + try + { + // Try to parse as ApiErrorResponse first + var apiErrorResponse = System.Text.Json.JsonSerializer.Deserialize(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; + } + } } diff --git a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs new file mode 100644 index 000000000..6e10f95d6 --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs @@ -0,0 +1,161 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.Enums; + +/// +/// 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. +/// +public enum ApiErrorCode +{ + /// + /// Refresh token is required but was not provided. + /// + REFRESH_TOKEN_REQUIRED, + + /// + /// User not found in the provided token. + /// + USER_NOT_FOUND_IN_TOKEN, + + /// + /// User not found in the database. + /// + USER_NOT_FOUND_IN_DATABASE, + + /// + /// User account is locked. + /// + ACCOUNT_LOCKED, + + /// + /// User account is blocked. + /// + ACCOUNT_BLOCKED, + + /// + /// The provided refresh token is invalid. + /// + INVALID_REFRESH_TOKEN, + + /// + /// Refresh token was successfully revoked. + /// + REFRESH_TOKEN_REVOKED_SUCCESSFULLY, + + /// + /// Public registration is disabled on this server. + /// + PUBLIC_REGISTRATION_DISABLED, + + /// + /// User not found. + /// + USER_NOT_FOUND, + + /// + /// Username is required but was not provided. + /// + USERNAME_REQUIRED, + + /// + /// Username is already in use. + /// + USERNAME_ALREADY_IN_USE, + + /// + /// Username is available. + /// + USERNAME_AVAILABLE, + + /// + /// Username does not match. + /// + USERNAME_MISMATCH, + + /// + /// Password does not match. + /// + PASSWORD_MISMATCH, + + /// + /// Account was successfully deleted. + /// + ACCOUNT_SUCCESSFULLY_DELETED, + + /// + /// Username cannot be empty or whitespace. + /// + USERNAME_EMPTY_OR_WHITESPACE, + + /// + /// Username is too short. + /// + USERNAME_TOO_SHORT, + + /// + /// Username is too long. + /// + USERNAME_TOO_LONG, + + /// + /// Username 'admin' is not allowed. + /// + USERNAME_ADMIN_NOT_ALLOWED, + + /// + /// Username is not a valid email address. + /// + USERNAME_INVALID_EMAIL, + + /// + /// Username contains invalid characters. + /// + USERNAME_INVALID_CHARACTERS, + + /// + /// There are pending database migrations. + /// + PENDING_MIGRATIONS, + + /// + /// System is OK. + /// + SYSTEM_OK, + + /// + /// Internal server error occurred. + /// + INTERNAL_SERVER_ERROR, + + /// + /// Generic vault error. + /// + VAULT_ERROR, + + /// + /// Unknown error occurred. + /// + UNKNOWN_ERROR, + + /// + /// Invalid authenticator code provided. + /// + INVALID_AUTHENTICATOR_CODE, + + /// + /// Invalid recovery code provided. + /// + INVALID_RECOVERY_CODE, + + /// + /// Vault is not up-to-date and requires synchronization. + /// + VAULT_NOT_UP_TO_DATE, +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/ApiErrorCodeHelper.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/ApiErrorCodeHelper.cs new file mode 100644 index 000000000..c54772c4f --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/ApiErrorCodeHelper.cs @@ -0,0 +1,96 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi; + +using AliasVault.Shared.Models.Enums; + +/// +/// Helper class for working with API error codes. +/// +public static class ApiErrorCodeHelper +{ + /// + /// Converts an ApiErrorCode enum value to its string representation. + /// + /// The error code to convert. + /// String representation of the error code. + public static string ToCode(this ApiErrorCode errorCode) + { + return errorCode.ToString(); + } + + /// + /// Creates an ApiErrorResponse with the specified error code and status. + /// + /// The error code. + /// The HTTP status code. + /// Optional additional details. + /// ApiErrorResponse object. + 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, + }; + } + + /// + /// Creates a ServerValidationErrorResponse with error codes instead of plain text. + /// + /// The error code. + /// The HTTP status code. + /// ServerValidationErrorResponse object. + public static ServerValidationErrorResponse CreateValidationErrorResponse(ApiErrorCode errorCode, int status) + { + var code = errorCode.ToCode(); + var errors = new Dictionary + { + { 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(), + }; + } + + /// + /// Creates a ServerValidationErrorResponse with multiple error codes. + /// + /// Array of error codes. + /// The HTTP status code. + /// ServerValidationErrorResponse object. + public static ServerValidationErrorResponse CreateValidationErrorResponse(ApiErrorCode[] errorCodes, int status) + { + var errors = new Dictionary(); + 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(), + }; + } +}