Skip to main content

ntex_basicauth/
error.rs

1//! Authentication error types and response handling
2
3use ntex::{http::StatusCode, web};
4use thiserror::Error;
5
6#[cfg(feature = "json")]
7use serde::{Deserialize, Serialize};
8
9/// Authentication error type
10#[derive(Debug, Error)]
11pub enum AuthError {
12    #[error("Missing Authorization header")]
13    /// Missing Authorization header
14    MissingHeader,
15
16    #[error("Invalid Authorization header format")]
17    /// Invalid format of Authorization header
18    InvalidFormat,
19
20    #[error("Invalid Base64 encoding")]
21    /// Invalid Base64 encoding
22    InvalidBase64,
23
24    #[error("Invalid user credentials")]
25    /// Invalid user credentials
26    InvalidCredentials,
27
28    #[error("User validation failed: {0}")]
29    /// User validation failed with a message
30    ValidationFailed(String),
31
32    #[error("Cache operation failed: {0}")]
33    /// Cache operation failed with a message
34    CacheError(String),
35
36    #[error("Configuration error: {0}")]
37    /// Configuration error with a message
38    ConfigError(String),
39
40    #[error("Internal server error: {0}")]
41    /// Internal server error with a message
42    InternalError(String),
43
44    #[error("Rate limit exceeded")]
45    /// Rate limit exceeded (too many authentication attempts)
46    RateLimited,
47}
48
49/// Authentication result type
50pub type AuthResult<T> = Result<T, AuthError>;
51
52/// Error response structure
53#[derive(Debug)]
54#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
55#[cfg_attr(not(feature = "json"), allow(dead_code))]
56struct AuthErrorResponse {
57    code: u16,
58    message: &'static str,
59    error: String,
60    #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))]
61    details: Option<String>,
62    #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))]
63    error_id: Option<String>,
64}
65
66impl AuthError {
67    /// Create HTTP 401 response with appropriate WWW-Authenticate header
68    pub fn to_response(&self, realm: &str) -> web::HttpResponse {
69        self.to_response_with_details(realm, None)
70    }
71
72    /// Create HTTP 401 response with custom details
73    pub fn to_response_with_details(
74        &self,
75        realm: &str,
76        details: Option<String>,
77    ) -> web::HttpResponse {
78        let (status_code, message) = match self {
79            AuthError::MissingHeader | AuthError::InvalidFormat | AuthError::InvalidBase64 => {
80                (StatusCode::UNAUTHORIZED, "Authentication required")
81            }
82            AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"),
83            AuthError::ValidationFailed(_) => (StatusCode::UNAUTHORIZED, "Validation failed"),
84            AuthError::ConfigError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Configuration error"),
85            AuthError::CacheError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cache error"),
86            AuthError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
87            AuthError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"),
88        };
89
90        let error_response = AuthErrorResponse {
91            code: status_code.as_u16(),
92            message,
93            error: self.to_string(),
94            details,
95            error_id: Some(self.error_id()),
96        };
97
98        #[cfg(feature = "json")]
99        let body = serde_json::to_string(&error_response)
100            .unwrap_or_else(|_| self.fallback_json_response());
101
102        #[cfg(not(feature = "json"))]
103        let body = format!(
104            r#"{{"code":{},"message":"{}","error":"{}"}}"#,
105            error_response.code,
106            error_response.message,
107            self.escape_json(&error_response.error)
108        );
109
110        let mut binding = web::HttpResponse::build(status_code);
111        let mut response = binding
112            .set_header("content-type", "application/json")
113            .set_header("cache-control", "no-store");
114
115        // Only add WWW-Authenticate header for authentication errors
116        if status_code == StatusCode::UNAUTHORIZED {
117            let www_authenticate = format!(
118                "Basic realm=\"{}\", charset=\"UTF-8\"",
119                self.escape_header_value(realm)
120            );
121            response = response.set_header("www-authenticate", www_authenticate);
122        }
123
124        response.body(body)
125    }
126
127    /// Generate error ID for log tracing
128    fn error_id(&self) -> String {
129        use std::collections::hash_map::DefaultHasher;
130        use std::hash::{Hash, Hasher};
131
132        let mut hasher = DefaultHasher::new();
133        std::mem::discriminant(self).hash(&mut hasher);
134        format!("AUTH_{:x}", hasher.finish())
135    }
136
137    #[cfg(not(feature = "json"))]
138    /// Escape JSON string (handles all control characters to prevent injection)
139    fn escape_json(&self, s: &str) -> String {
140        let mut out = String::with_capacity(s.len() + 2);
141        for c in s.chars() {
142            match c {
143                '\\' => out.push_str("\\\\"),
144                '"' => out.push_str("\\\""),
145                '\n' => out.push_str("\\n"),
146                '\r' => out.push_str("\\r"),
147                '\t' => out.push_str("\\t"),
148                c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
149                c => out.push(c),
150            }
151        }
152        out
153    }
154
155    /// Escape HTTP header value: strip control characters (CRLF injection
156    /// prevention) and escape quotes/backslashes for the quoted-string form.
157    fn escape_header_value(&self, s: &str) -> String {
158        let mut out = String::with_capacity(s.len());
159        for c in s.chars() {
160            match c {
161                '"' => out.push_str("\\\""),
162                '\\' => out.push_str("\\\\"),
163                c if c.is_control() => {}
164                c => out.push(c),
165            }
166        }
167        out
168    }
169
170    /// Fallback JSON response (when serialization fails)
171    #[cfg(feature = "json")]
172    fn fallback_json_response(&self) -> String {
173        r#"{"code":500,"message":"Internal error","error":"Response serialization failed"}"#
174            .to_string()
175    }
176
177    /// Check if this is a client error
178    pub fn is_client_error(&self) -> bool {
179        matches!(
180            self,
181            AuthError::MissingHeader
182                | AuthError::InvalidFormat
183                | AuthError::InvalidBase64
184                | AuthError::InvalidCredentials
185                | AuthError::RateLimited
186        )
187    }
188
189    /// Check if this is a server error
190    pub fn is_server_error(&self) -> bool {
191        !self.is_client_error()
192    }
193
194    /// Get suggested log level
195    pub fn log_level(&self) -> &'static str {
196        match self {
197            AuthError::MissingHeader | AuthError::InvalidCredentials => "info",
198            AuthError::InvalidFormat | AuthError::InvalidBase64 => "warn",
199            AuthError::ValidationFailed(_) => "warn",
200            AuthError::ConfigError(_) | AuthError::InternalError(_) => "error",
201            AuthError::CacheError(_) | AuthError::RateLimited => "warn",
202        }
203    }
204}
205
206impl web::error::WebResponseError for AuthError {
207    fn status_code(&self) -> StatusCode {
208        match self {
209            AuthError::MissingHeader
210            | AuthError::InvalidFormat
211            | AuthError::InvalidBase64
212            | AuthError::InvalidCredentials
213            | AuthError::ValidationFailed(_) => StatusCode::UNAUTHORIZED,
214
215            AuthError::ConfigError(_) | AuthError::CacheError(_) | AuthError::InternalError(_) => {
216                StatusCode::INTERNAL_SERVER_ERROR
217            }
218
219            AuthError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
220        }
221    }
222
223    fn error_response(&self, _req: &ntex::web::HttpRequest) -> web::HttpResponse {
224        self.to_response("Restricted Area")
225    }
226}
227
228/// Convenience macro for creating errors
229#[macro_export]
230macro_rules! auth_error {
231    (missing_header) => {
232        $crate::AuthError::MissingHeader
233    };
234    (invalid_format) => {
235        $crate::AuthError::InvalidFormat
236    };
237    (invalid_base64) => {
238        $crate::AuthError::InvalidBase64
239    };
240    (invalid_credentials) => {
241        $crate::AuthError::InvalidCredentials
242    };
243    (validation_failed, $msg:expr) => {
244        $crate::AuthError::ValidationFailed($msg.to_string())
245    };
246    (cache_error, $msg:expr) => {
247        $crate::AuthError::CacheError($msg.to_string())
248    };
249    (config_error, $msg:expr) => {
250        $crate::AuthError::ConfigError($msg.to_string())
251    };
252    (internal_error, $msg:expr) => {
253        $crate::AuthError::InternalError($msg.to_string())
254    };
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_error_classification() {
263        assert!(AuthError::MissingHeader.is_client_error());
264        assert!(AuthError::InvalidCredentials.is_client_error());
265        assert!(AuthError::ConfigError("test".to_string()).is_server_error());
266        assert!(AuthError::InternalError("test".to_string()).is_server_error());
267    }
268
269    #[test]
270    fn test_error_id_consistency() {
271        let error1 = AuthError::MissingHeader;
272        let error2 = AuthError::MissingHeader;
273        assert_eq!(error1.error_id(), error2.error_id());
274
275        let error3 = AuthError::InvalidCredentials;
276        assert_ne!(error1.error_id(), error3.error_id());
277    }
278
279    #[test]
280    fn test_escape_header_value_strips_crlf() {
281        let error = AuthError::MissingHeader;
282        // CRLF must be stripped to prevent HTTP response header injection
283        // via a crafted realm value.
284        let escaped = error.escape_header_value("safe\r\nX-Injected: evil");
285        assert!(!escaped.contains('\r'));
286        assert!(!escaped.contains('\n'));
287        // Without CRLF, "X-Injected" can no longer start a new header line;
288        // it just remains literal text inside the quoted realm value.
289        assert!(escaped.contains("X-Injected"));
290    }
291
292    #[cfg(not(feature = "json"))]
293    #[test]
294    fn test_json_escaping() {
295        let error =
296            AuthError::ValidationFailed("Message with \"quotes\" and\nnew line".to_string());
297        let escaped = error.escape_json(&error.to_string());
298        assert!(!escaped.contains('\n'));
299        assert!(escaped.contains("\\\""));
300    }
301
302    #[test]
303    fn test_macro_usage() {
304        let _error1 = auth_error!(missing_header);
305        let _error2 = auth_error!(validation_failed, "Custom message");
306        let _error3 = auth_error!(config_error, "Configuration issue");
307    }
308}