Skip to main content

aria2_core/auth/
basic_auth.rs

1//! Basic HTTP Authentication (RFC 7617)
2//!
3//! Implements the simple username/password authentication scheme where credentials
4//! are Base64-encoded and sent in the Authorization header.
5//!
6//! # Security Considerations
7//! - Basic auth transmits credentials in an easily decoded format (Base64)
8//! - Always use HTTPS to prevent credential interception
9//! - This module provides an `https_only` option to enforce secure connections
10//! - All credentials are wrapped in `Secret<T>` for automatic memory zeroing
11
12use base64::Engine;
13use std::fmt;
14use url::Url;
15
16use crate::auth::digest_auth::{AuthChallenge, AuthProvider, AuthScheme, Secret};
17use crate::error::{Aria2Error, Result};
18
19/// Basic authentication provider implementing RFC 7617.
20///
21/// Provides simple username/password authentication with optional HTTPS enforcement
22/// to prevent credential leakage over insecure connections.
23///
24/// # Example
25/// ```rust
26/// use aria2_core::auth::basic_auth::BasicAuthProvider;
27///
28/// let provider = BasicAuthProvider::new(
29///     "alice".to_string(),
30///     "secret-password".to_string(),
31///     true, // Enforce HTTPS
32/// );
33/// ```
34#[derive(Clone)]
35pub struct BasicAuthProvider {
36    /// Username for authentication (stored securely)
37    username: Secret<String>,
38    /// Password for authentication (stored securely)
39    password: Secret<String>,
40    /// When true, only allow authentication over HTTPS connections
41    https_only: bool,
42}
43
44impl BasicAuthProvider {
45    /// Creates a new Basic authentication provider.
46    ///
47    /// # Arguments
48    /// * `username` - The username for authentication
49    /// * `password` - The password for authentication
50    /// * `https_only` - If true, rejects non-HTTPS URLs for security
51    ///
52    /// # Security Note
53    /// Setting `https_only` to `true` is strongly recommended to prevent
54    /// credential interception. Basic auth credentials are trivially decoded
55    /// from Base64, making HTTPS essential for security.
56    pub fn new(username: String, password: String, https_only: bool) -> Self {
57        BasicAuthProvider {
58            username: Secret::new(username),
59            password: Secret::new(password),
60            https_only,
61        }
62    }
63
64    /// Returns whether this provider enforces HTTPS-only mode.
65    pub fn is_https_only(&self) -> bool {
66        self.https_only
67    }
68
69    /// Builds an Authorization header value with URL validation.
70    ///
71    /// This is the recommended method as it validates the URL scheme before
72    /// generating credentials when `https_only` is enabled.
73    ///
74    /// # Arguments
75    /// * `challenge` - The authentication challenge (realm info)
76    /// * `url` - The request URL (used for scheme validation)
77    ///
78    /// # Returns
79    /// Complete Authorization header value or error if:
80    /// - URL scheme is not HTTPS and `https_only` is enabled
81    /// - Base64 encoding fails (unlikely but possible)
82    ///
83    /// # Errors
84    /// Returns `Aria2Error::Parse` if the URL cannot be parsed
85    /// Returns `Aria2Error::DownloadFailed` if HTTPS is required but not used
86    pub fn build_authorization_header_with_url(
87        &self,
88        challenge: &AuthChallenge,
89        url: &str,
90    ) -> Result<String> {
91        if self.https_only {
92            let parsed_url =
93                Url::parse(url).map_err(|e| Aria2Error::Parse(format!("Invalid URL: {}", e)))?;
94
95            if parsed_url.scheme() != "https" {
96                return Err(Aria2Error::DownloadFailed(
97                    "Basic authentication requires HTTPS connection to prevent credential \
98                     interception"
99                        .to_string(),
100                ));
101            }
102        }
103
104        self.build_authorization_header(challenge)
105    }
106
107    /// Generates the Base64-encoded credential string.
108    ///
109    /// Creates "username:password" and encodes it using standard Base64.
110    fn encode_credentials(&self) -> String {
111        let creds = format!(
112            "{}:{}",
113            self.username.expose_secret(),
114            self.password.expose_secret()
115        );
116        base64::engine::general_purpose::STANDARD.encode(creds)
117    }
118}
119
120impl fmt::Debug for BasicAuthProvider {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.debug_struct("BasicAuthProvider")
123            .field("username", &self.username)
124            .field("password", &self.password)
125            .field("https_only", &self.https_only)
126            .finish()
127    }
128}
129
130#[async_trait::async_trait]
131impl AuthProvider for BasicAuthProvider {
132    fn scheme(&self) -> AuthScheme {
133        AuthScheme::Basic
134    }
135
136    /// Builds a Basic Authorization header value.
137    ///
138    /// Format: `Basic Base64(username:password)`
139    ///
140    /// # Arguments
141    /// * `_challenge` - The authentication challenge (not used for Basic auth,
142    ///   but included for interface consistency)
143    ///
144    /// # Returns
145    /// Complete Authorization header starting with "Basic "
146    fn build_authorization_header(&self, _challenge: &AuthChallenge) -> Result<String> {
147        let encoded = self.encode_credentials();
148        Ok(format!("Basic {}", encoded))
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_basic_auth_construction() {
158        let provider =
159            BasicAuthProvider::new("testuser".to_string(), "testpass".to_string(), false);
160
161        assert!(!provider.is_https_only());
162        assert_eq!(provider.scheme(), AuthScheme::Basic);
163    }
164
165    #[test]
166    fn test_basic_auth_with_https_enforcement() {
167        let provider =
168            BasicAuthProvider::new("secure_user".to_string(), "secure_pass".to_string(), true);
169
170        assert!(provider.is_https_only());
171    }
172
173    #[test]
174    fn test_basic_auth_debug_masking() {
175        let provider =
176            BasicAuthProvider::new("admin".to_string(), "super-secret".to_string(), true);
177
178        let debug_output = format!("{:?}", provider);
179
180        // Verify that sensitive data is masked
181        assert!(debug_output.contains("Secret(***)"));
182        assert!(!debug_output.contains("super-secret"));
183        assert!(!debug_output.contains("admin"));
184    }
185
186    #[test]
187    fn test_basic_auth_clone() {
188        let provider =
189            BasicAuthProvider::new("original".to_string(), "password".to_string(), false);
190
191        let cloned = provider.clone();
192
193        // Both should work independently
194        let challenge = AuthChallenge {
195            scheme: AuthScheme::Basic,
196            realm: "Test".to_string(),
197            nonce: None,
198            opaque: None,
199            qop: None,
200            stale: false,
201        };
202
203        let result1 = provider.build_authorization_header(&challenge).unwrap();
204        let result2 = cloned.build_authorization_header(&challenge).unwrap();
205
206        assert_eq!(result1, result2);
207    }
208
209    #[test]
210    fn test_basic_auth_special_characters_in_credentials() {
211        // Test with special characters that might affect Base64 encoding
212        let provider = BasicAuthProvider::new(
213            "user@domain.com".to_string(),
214            "p@ss:w0rd!".to_string(),
215            false,
216        );
217
218        let challenge = AuthChallenge {
219            scheme: AuthScheme::Basic,
220            realm: "Test".to_string(),
221            nonce: None,
222            opaque: None,
223            qop: None,
224            stale: false,
225        };
226
227        let result = provider.build_authorization_header(&challenge).unwrap();
228        assert!(result.starts_with("Basic "));
229
230        // Verify we can decode it back
231        let encoded_part = result.trim_start_matches("Basic ");
232        let decoded = base64::engine::general_purpose::STANDARD
233            .decode(encoded_part)
234            .unwrap();
235        let decoded_str = String::from_utf8(decoded).unwrap();
236        assert_eq!(decoded_str, "user@domain.com:p@ss:w0rd!");
237    }
238
239    #[test]
240    fn test_basic_auth_empty_password() {
241        // Edge case: empty password should still work
242        let provider = BasicAuthProvider::new("user".to_string(), "".to_string(), false);
243
244        let challenge = AuthChallenge {
245            scheme: AuthScheme::Basic,
246            realm: "Test".to_string(),
247            nonce: None,
248            opaque: None,
249            qop: None,
250            stale: false,
251        };
252
253        let result = provider.build_authorization_header(&challenge).unwrap();
254        // Should be Base64 of "user:"
255        assert_eq!(result, "Basic dXNlcjo=");
256    }
257
258    #[test]
259    fn test_basic_auth_unicode_credentials() {
260        // Test Unicode characters in credentials
261        let provider = BasicAuthProvider::new("用户名".to_string(), "密码".to_string(), false);
262
263        let challenge = AuthChallenge {
264            scheme: AuthScheme::Basic,
265            realm: "Test".to_string(),
266            nonce: None,
267            opaque: None,
268            qop: None,
269            stale: false,
270        };
271
272        let result = provider.build_authorization_header(&challenge).unwrap();
273        assert!(result.starts_with("Basic "));
274
275        // Verify encoding/decoding round-trip
276        let encoded_part = result.trim_start_matches("Basic ");
277        let decoded = base64::engine::general_purpose::STANDARD
278            .decode(encoded_part)
279            .unwrap();
280        let decoded_str = String::from_utf8(decoded).unwrap();
281        assert_eq!(decoded_str, "用户名:密码");
282    }
283}