Skip to main content

nntp_proxy/auth/
handler.rs

1//! Client authentication handling
2
3use crate::command::AuthAction;
4use crate::protocol::{AUTH_ACCEPTED, AUTH_FAILED, AUTH_REQUIRED};
5use crate::types::{Password, Username, ValidationError};
6use std::collections::HashMap;
7use tokio::io::AsyncWriteExt;
8
9/// Handles client-facing authentication interception
10#[derive(Default)]
11pub struct AuthHandler {
12    /// Map of username -> password for O(1) lookups
13    users: HashMap<String, String>,
14}
15
16impl std::fmt::Debug for AuthHandler {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.debug_struct("AuthHandler")
19            .field("enabled", &!self.users.is_empty())
20            .field("user_count", &self.users.len())
21            .finish_non_exhaustive()
22    }
23}
24
25impl AuthHandler {
26    /// Create a new auth handler with optional credentials
27    ///
28    /// # Authentication behavior:
29    /// - `None, None` → Auth disabled (allows all connections)
30    /// - `Some(user), Some(pass)` → Auth enabled with validation
31    /// - `Some(user), None` or `None, Some(pass)` → Auth disabled (both must be provided)
32    ///
33    /// # Errors
34    /// Returns `Err` if either username or password is explicitly provided but empty/whitespace.
35    /// This prevents misconfiguration where empty credentials would silently disable auth,
36    /// which is a critical security vulnerability.
37    ///
38    /// # Security
39    /// If you explicitly set credentials in config and they're empty, the proxy will
40    /// **refuse to start** rather than silently running with no authentication.
41    pub fn new(
42        username: Option<String>,
43        password: Option<String>,
44    ) -> Result<Self, ValidationError> {
45        let mut users = HashMap::new();
46
47        if let (Some(u), Some(p)) = (username, password) {
48            // Both provided - validate they're non-empty
49            let username = Username::try_new(u.clone())?; // Returns Err if empty
50            let password = Password::try_new(p.clone())?; // Returns Err if empty
51            users.insert(username.as_str().to_string(), password.as_str().to_string());
52        }
53
54        Ok(Self { users })
55    }
56
57    /// Create a new auth handler with multiple users
58    ///
59    /// # Errors
60    /// Returns `Err` if any username or password is empty/whitespace.
61    pub fn with_users(user_list: Vec<(String, String)>) -> Result<Self, ValidationError> {
62        let mut users = HashMap::new();
63
64        for (u, p) in user_list {
65            // Validate each credential pair
66            let username = Username::try_new(u.clone())?;
67            let password = Password::try_new(p.clone())?;
68            users.insert(username.as_str().to_string(), password.as_str().to_string());
69        }
70
71        Ok(Self { users })
72    }
73
74    /// Check if authentication is enabled
75    #[inline]
76    pub fn is_enabled(&self) -> bool {
77        !self.users.is_empty()
78    }
79
80    /// Validate client credentials
81    ///
82    /// If auth is disabled (no users configured), returns true for all credentials
83    pub fn validate_credentials(&self, username: &str, password: &str) -> bool {
84        if self.users.is_empty() {
85            // Auth disabled - allow all
86            true
87        } else {
88            // Auth enabled - validate credentials
89            self.users
90                .get(username)
91                .is_some_and(|stored_pass| stored_pass == password)
92        }
93    }
94
95    /// Handle an auth command - writes response to client and returns (bytes_written, auth_success)
96    /// This is the ONE place where auth interception happens
97    pub async fn handle_auth_command<W>(
98        &self,
99        auth_action: AuthAction<'_>,
100        writer: &mut W,
101        stored_username: Option<&str>,
102    ) -> std::io::Result<(usize, bool)>
103    where
104        W: AsyncWriteExt + Unpin,
105    {
106        match auth_action {
107            AuthAction::RequestPassword(_username) => {
108                // Always respond with password required
109                writer.write_all(AUTH_REQUIRED).await?;
110                Ok((AUTH_REQUIRED.len(), false))
111            }
112            AuthAction::ValidateAndRespond { password } => {
113                // Validate credentials
114                let auth_success = if let Some(username) = stored_username {
115                    self.validate_credentials(username, password)
116                } else {
117                    // No username was stored (client sent AUTHINFO PASS without USER)
118                    false
119                };
120
121                let response = if auth_success {
122                    AUTH_ACCEPTED
123                } else {
124                    AUTH_FAILED
125                };
126                writer.write_all(response).await?;
127                Ok((response.len(), auth_success))
128            }
129        }
130    }
131
132    /// Get the AUTHINFO USER response
133    #[inline]
134    pub const fn user_response(&self) -> &'static [u8] {
135        AUTH_REQUIRED
136    }
137
138    /// Get the AUTHINFO PASS response
139    #[inline]
140    pub const fn pass_response(&self) -> &'static [u8] {
141        AUTH_ACCEPTED
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn test_handler() -> AuthHandler {
150        AuthHandler::default()
151    }
152
153    mod auth_handler {
154        use super::*;
155
156        #[test]
157        fn test_default() {
158            let handler = AuthHandler::default();
159            assert!(!handler.is_enabled());
160        }
161
162        #[test]
163        fn test_new_with_both_credentials() {
164            let handler =
165                AuthHandler::new(Some("user".to_string()), Some("pass".to_string())).unwrap();
166            assert!(handler.is_enabled());
167        }
168
169        #[test]
170        fn test_new_with_only_username() {
171            let handler = AuthHandler::new(Some("user".to_string()), None).unwrap();
172            assert!(!handler.is_enabled());
173        }
174
175        #[test]
176        fn test_new_with_only_password() {
177            let handler = AuthHandler::new(None, Some("pass".to_string())).unwrap();
178            assert!(!handler.is_enabled());
179        }
180
181        #[test]
182        fn test_new_with_neither() {
183            let handler = AuthHandler::new(None, None).unwrap();
184            assert!(!handler.is_enabled());
185        }
186
187        #[test]
188        fn test_with_users_multiple() {
189            let users = vec![
190                ("alice".to_string(), "secret1".to_string()),
191                ("bob".to_string(), "secret2".to_string()),
192                ("charlie".to_string(), "secret3".to_string()),
193            ];
194            let handler = AuthHandler::with_users(users).unwrap();
195            assert!(handler.is_enabled());
196            assert!(handler.validate_credentials("alice", "secret1"));
197            assert!(handler.validate_credentials("bob", "secret2"));
198            assert!(handler.validate_credentials("charlie", "secret3"));
199            assert!(!handler.validate_credentials("alice", "wrong"));
200            assert!(!handler.validate_credentials("bob", "secret1")); // Wrong password for bob
201            assert!(!handler.validate_credentials("dave", "anything")); // Unknown user
202        }
203
204        #[test]
205        fn test_with_users_empty() {
206            let handler = AuthHandler::with_users(vec![]).unwrap();
207            assert!(!handler.is_enabled());
208            assert!(handler.validate_credentials("anyone", "anything")); // No auth, allow all
209        }
210
211        #[test]
212        fn test_with_users_rejects_empty_username() {
213            let users = vec![
214                ("alice".to_string(), "pass1".to_string()),
215                ("".to_string(), "pass2".to_string()), // Empty username
216            ];
217            let result = AuthHandler::with_users(users);
218            assert!(result.is_err());
219        }
220
221        #[test]
222        fn test_with_users_rejects_empty_password() {
223            let users = vec![
224                ("alice".to_string(), "pass1".to_string()),
225                ("bob".to_string(), "".to_string()), // Empty password
226            ];
227            let result = AuthHandler::with_users(users);
228            assert!(result.is_err());
229        }
230
231        #[test]
232        fn test_new_with_empty_username_fails() {
233            let result = AuthHandler::new(Some("".to_string()), Some("pass".to_string()));
234            assert!(result.is_err(), "Empty username should return error");
235        }
236
237        #[test]
238        fn test_new_with_empty_password_fails() {
239            let result = AuthHandler::new(Some("user".to_string()), Some("".to_string()));
240            assert!(result.is_err(), "Empty password should return error");
241        }
242
243        #[test]
244        fn test_new_with_whitespace_username_fails() {
245            let result = AuthHandler::new(Some("   ".to_string()), Some("pass".to_string()));
246            assert!(
247                result.is_err(),
248                "Whitespace-only username should return error"
249            );
250        }
251
252        #[test]
253        fn test_new_with_whitespace_password_fails() {
254            let result = AuthHandler::new(Some("user".to_string()), Some("   ".to_string()));
255            assert!(
256                result.is_err(),
257                "Whitespace-only password should return error"
258            );
259        }
260
261        #[test]
262        fn test_validate_when_disabled() {
263            let handler = AuthHandler::new(None, None).unwrap();
264            assert!(handler.validate_credentials("any", "thing"));
265            assert!(handler.validate_credentials("", ""));
266            assert!(handler.validate_credentials("foo", "bar"));
267        }
268
269        #[test]
270        fn test_validate_when_enabled() {
271            let handler =
272                AuthHandler::new(Some("alice".to_string()), Some("secret".to_string())).unwrap();
273            assert!(handler.validate_credentials("alice", "secret"));
274            assert!(!handler.validate_credentials("alice", "wrong"));
275            assert!(!handler.validate_credentials("bob", "secret"));
276            assert!(!handler.validate_credentials("bob", "wrong"));
277        }
278
279        #[test]
280        fn test_is_enabled_consistent() {
281            let disabled = AuthHandler::new(None, None).unwrap();
282            assert!(!disabled.is_enabled());
283            assert!(!disabled.is_enabled()); // Call twice to ensure consistency
284
285            let enabled = AuthHandler::new(Some("u".to_string()), Some("p".to_string())).unwrap();
286            assert!(enabled.is_enabled());
287            assert!(enabled.is_enabled()); // Call twice to ensure consistency
288        }
289    }
290
291    #[test]
292    fn test_user_response() {
293        let handler = test_handler();
294        let response = handler.user_response();
295        let response_str = String::from_utf8_lossy(response);
296
297        // Should be 381 Password required
298        assert!(response_str.starts_with("381"));
299        assert!(response_str.contains("Password required") || response_str.contains("password"));
300        assert!(response_str.ends_with("\r\n"));
301    }
302
303    #[test]
304    fn test_pass_response() {
305        let handler = test_handler();
306        let response = handler.pass_response();
307        let response_str = String::from_utf8_lossy(response);
308
309        // Should be 281 Authentication accepted
310        assert!(response_str.starts_with("281"));
311        assert!(response_str.contains("accepted") || response_str.contains("Authentication"));
312        assert!(response_str.ends_with("\r\n"));
313    }
314
315    #[test]
316    fn test_responses_are_static() {
317        // Verify responses are the same each time (static)
318        let handler = test_handler();
319        let response1 = handler.user_response();
320        let response2 = handler.user_response();
321        assert_eq!(response1.as_ptr(), response2.as_ptr());
322
323        let response3 = handler.pass_response();
324        let response4 = handler.pass_response();
325        assert_eq!(response3.as_ptr(), response4.as_ptr());
326    }
327
328    #[test]
329    fn test_responses_are_different() {
330        // User and pass responses should be different
331        let handler = test_handler();
332        let user_resp = handler.user_response();
333        let pass_resp = handler.pass_response();
334        assert_ne!(user_resp, pass_resp);
335    }
336
337    #[test]
338    fn test_responses_are_valid_utf8() {
339        // Ensure responses are valid UTF-8
340        let handler = test_handler();
341        let user_resp = handler.user_response();
342        assert!(std::str::from_utf8(user_resp).is_ok());
343
344        let pass_resp = handler.pass_response();
345        assert!(std::str::from_utf8(pass_resp).is_ok());
346    }
347
348    #[test]
349    fn test_auth_disabled_by_default() {
350        let handler = AuthHandler::default();
351        assert!(!handler.is_enabled());
352        assert!(handler.validate_credentials("any", "thing")); // Should accept anything
353    }
354
355    #[test]
356    fn test_auth_new_none_none() {
357        let handler = AuthHandler::new(None, None).unwrap();
358        assert!(!handler.is_enabled());
359        assert!(handler.validate_credentials("any", "thing"));
360    }
361
362    #[test]
363    fn test_auth_enabled_with_credentials() {
364        let handler =
365            AuthHandler::new(Some("mjc".to_string()), Some("nntp1337".to_string())).unwrap();
366        assert!(handler.is_enabled());
367        assert!(handler.validate_credentials("mjc", "nntp1337"));
368        assert!(!handler.validate_credentials("mjc", "wrong"));
369        assert!(!handler.validate_credentials("wrong", "nntp1337"));
370    }
371
372    #[test]
373    fn test_security_empty_credentials_rejected() {
374        // SECURITY: Empty username must fail
375        let result = AuthHandler::new(Some("".to_string()), Some("pass".to_string()));
376        assert!(
377            result.is_err(),
378            "Empty username should be rejected to prevent silent auth bypass"
379        );
380
381        // SECURITY: Empty password must fail
382        let result = AuthHandler::new(Some("user".to_string()), Some("".to_string()));
383        assert!(
384            result.is_err(),
385            "Empty password should be rejected to prevent silent auth bypass"
386        );
387
388        // SECURITY: Both empty must fail
389        let result = AuthHandler::new(Some("".to_string()), Some("".to_string()));
390        assert!(
391            result.is_err(),
392            "Both empty should be rejected to prevent silent auth bypass"
393        );
394    }
395
396    #[test]
397    fn test_security_whitespace_credentials_rejected() {
398        // SECURITY: Whitespace-only username must fail
399        let result = AuthHandler::new(Some("   ".to_string()), Some("pass".to_string()));
400        assert!(
401            result.is_err(),
402            "Whitespace-only username should be rejected"
403        );
404
405        // SECURITY: Whitespace-only password must fail
406        let result = AuthHandler::new(Some("user".to_string()), Some("   ".to_string()));
407        assert!(
408            result.is_err(),
409            "Whitespace-only password should be rejected"
410        );
411    }
412
413    #[test]
414    fn test_security_explicit_config_prevents_silent_bypass() {
415        // This test demonstrates the security fix:
416        // If someone sets credentials in config but they're empty,
417        // we MUST fail rather than silently disable auth.
418        //
419        // Before fix: Empty credentials = auth silently disabled = MASSIVE SECURITY HOLE
420        // After fix: Empty credentials = proxy refuses to start = SAFE
421
422        // Simulate someone setting credentials in config
423        let username_from_config = Some("".to_string()); // Typo or misconfiguration
424        let password_from_config = Some("secret".to_string());
425
426        let result = AuthHandler::new(username_from_config, password_from_config);
427
428        assert!(
429            result.is_err(),
430            "Proxy must refuse to start with empty credentials from config. \
431             Silently disabling auth would be a critical security vulnerability!"
432        );
433    }
434}