error.rs
301 lignes · 10565 octets
use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; use serde_json::json; use thiserror::Error; /// Application error types /// /// This module defines all error types used throughout the application. /// Errors are converted to appropriate HTTP responses for API endpoints. #[derive(Error, Debug)] pub enum AppError { /// A SeaORM database operation failed. #[error("Database error: {0}")] Database(#[from] sea_orm::DbErr), /// User-supplied input failed validation. The message describes what was invalid. #[error("Validation error: {0}")] Validation(String), /// The requested resource could not be found. #[error("Resource not found: {0}")] NotFound(String), /// An unexpected internal error occurred. The message contains diagnostic detail. #[error("Internal server error: {0}")] Internal(String), /// The supplied username/password combination is incorrect. #[error("Invalid credentials")] InvalidCredentials, /// The supplied token is malformed or does not match any known record. #[error("Invalid token")] InvalidToken, /// The token was valid but its expiration time has passed. #[error("Token expired")] TokenExpired, /// A refresh token was presented that had already been used, indicating possible token theft. #[error("Token reused - possible security breach")] TokenReused, /// The user must set a new password before accessing the application. #[allow(dead_code)] #[error("Password change required")] MustChangePassword, /// The request requires authentication but no valid credentials were provided. #[allow(dead_code)] #[error("Authentication required")] Unauthorized, /// The authenticated user does not have permission to perform this action. #[allow(dead_code)] #[error("Insufficient permissions")] Forbidden, /// A required configuration value is missing or invalid. #[error("Configuration error: {0}")] Config(String), /// An Askama template failed to render. #[error("Template rendering error: {0}")] Template(String), /// The submitted TOTP code is incorrect. #[error("TOTP verification failed")] TotpFailed, /// The TOTP challenge token has expired or does not exist. #[error("TOTP challenge expired or invalid")] TotpChallengeInvalid, /// The TOTP challenge was failed too many times and is now locked. #[error("TOTP challenge max attempts exceeded")] TotpMaxAttemptsExceeded, /// The account has TOTP enabled; a second-factor code must be provided to continue. #[error("Two-factor authentication required")] TotpRequired, /// TOTP cannot be enabled because it is already active for this user. #[allow(dead_code)] #[error("TOTP already enabled")] TotpAlreadyEnabled, /// The requested operation requires TOTP to be enabled, but it is not. #[allow(dead_code)] #[error("TOTP not enabled")] TotpNotEnabled, /// Client exceeded a rate limit. Carries the `Retry-After` header value (seconds). #[error("Too many requests, retry after {retry_after_secs}s")] TooManyRequests { /// Number of seconds the client should wait before retrying. retry_after_secs: u64, }, } impl IntoResponse for AppError { fn into_response(self) -> Response { // 429 has a required Retry-After header, so handle it before the generic // (status, body) path used by other variants. if let AppError::TooManyRequests { retry_after_secs } = self { tracing::warn!( retry_after_secs, "Rate limit exceeded, returning 429 Too Many Requests" ); let body = Json(json!({ "error": "Too many requests, please retry later", "status": StatusCode::TOO_MANY_REQUESTS.as_u16(), "retry_after_secs": retry_after_secs, })); return ( StatusCode::TOO_MANY_REQUESTS, [("Retry-After", retry_after_secs.to_string())], body, ) .into_response(); } let (status, error_message) = match self { AppError::Database(err) => { tracing::error!("Database error: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, "Database error occurred".to_string(), ) } AppError::Validation(msg) => { tracing::warn!("Validation error: {}", msg); ( StatusCode::BAD_REQUEST, format!("Validation error: {}", msg), ) } AppError::NotFound(msg) => { tracing::info!("Not found: {}", msg); ( StatusCode::NOT_FOUND, format!("Resource not found: {}", msg), ) } AppError::Internal(msg) => { tracing::error!("Internal error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string(), ) } AppError::InvalidCredentials => { tracing::warn!("Authentication attempt with invalid credentials"); (StatusCode::UNAUTHORIZED, "Invalid credentials".to_string()) } AppError::InvalidToken => { tracing::warn!("Invalid token provided"); (StatusCode::UNAUTHORIZED, "Invalid token".to_string()) } AppError::TokenExpired => { tracing::debug!("Token expired"); (StatusCode::UNAUTHORIZED, "Token expired".to_string()) } AppError::TokenReused => { tracing::error!("Refresh token reuse detected - possible security breach"); ( StatusCode::UNAUTHORIZED, "Token reused - please re-authenticate".to_string(), ) } AppError::MustChangePassword => { tracing::info!("User must change password"); ( StatusCode::FORBIDDEN, "Password change required".to_string(), ) } AppError::Unauthorized => { tracing::warn!("Unauthorized access attempt"); ( StatusCode::UNAUTHORIZED, "Authentication required".to_string(), ) } AppError::Forbidden => { tracing::warn!("Forbidden access attempt"); ( StatusCode::FORBIDDEN, "Insufficient permissions".to_string(), ) } AppError::Config(msg) => { tracing::error!("Configuration error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "Server configuration error".to_string(), ) } AppError::Template(msg) => { tracing::error!("Template rendering error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, "Template error".to_string(), ) } AppError::TotpFailed => { tracing::warn!("TOTP verification failed"); ( StatusCode::UNAUTHORIZED, "TOTP verification failed".to_string(), ) } AppError::TotpChallengeInvalid => { tracing::warn!("TOTP challenge expired or invalid"); ( StatusCode::UNAUTHORIZED, "TOTP challenge expired or invalid".to_string(), ) } AppError::TotpMaxAttemptsExceeded => { tracing::warn!("TOTP challenge max attempts exceeded"); ( StatusCode::TOO_MANY_REQUESTS, "Too many TOTP attempts".to_string(), ) } AppError::TotpRequired => { tracing::info!("Two-factor authentication setup required"); ( StatusCode::FORBIDDEN, "Two-factor authentication required".to_string(), ) } AppError::TotpAlreadyEnabled => { tracing::info!("TOTP already enabled for user"); ( StatusCode::BAD_REQUEST, "TOTP is already enabled".to_string(), ) } AppError::TotpNotEnabled => { tracing::info!("TOTP not enabled for user"); (StatusCode::BAD_REQUEST, "TOTP is not enabled".to_string()) } AppError::TooManyRequests { .. } => { unreachable!("TooManyRequests is handled above to include the Retry-After header") } }; let body = Json(json!({ "error": error_message, "status": status.as_u16() })); (status, body).into_response() } } #[cfg(test)] mod tests { use super::*; use axum::body::to_bytes; #[tokio::test] async fn too_many_requests_returns_429_with_retry_after_header() { let err = AppError::TooManyRequests { retry_after_secs: 42, }; let response = err.into_response(); assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); let retry_after = response .headers() .get("Retry-After") .expect("Retry-After header must be set for 429 responses"); assert_eq!(retry_after.to_str().unwrap(), "42"); } #[tokio::test] async fn too_many_requests_body_contains_error_message() { let err = AppError::TooManyRequests { retry_after_secs: 1, }; let response = err.into_response(); let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(body["status"], 429); assert!( body["error"] .as_str() .unwrap() .to_lowercase() .contains("too many") ); } }
GitRust