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_OUT_OF_SEQUENCE, 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)?; // Returns Err if empty
50            let password = Password::try_new(p)?; // 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    #[must_use]
77    pub fn is_enabled(&self) -> bool {
78        !self.users.is_empty()
79    }
80
81    /// Validate client credentials
82    ///
83    /// If auth is disabled (no users configured), returns true for all credentials
84    #[must_use]
85    pub fn validate_credentials(&self, username: &str, password: &str) -> bool {
86        if self.users.is_empty() {
87            // Auth disabled - allow all
88            true
89        } else {
90            // Auth enabled - validate credentials
91            self.users
92                .get(username)
93                .is_some_and(|stored_pass| stored_pass == password)
94        }
95    }
96
97    /// Handle an auth command - writes response to client and returns (`bytes_written`, `auth_success`)
98    /// This is the ONE place where auth interception happens.
99    ///
100    /// # Errors
101    /// Returns any I/O error from writing the NNTP auth response to the client.
102    pub async fn handle_auth_command<W>(
103        &self,
104        auth_action: AuthAction<'_>,
105        writer: &mut W,
106        stored_username: Option<&str>,
107    ) -> std::io::Result<(usize, bool)>
108    where
109        W: AsyncWriteExt + Unpin,
110    {
111        match auth_action {
112            AuthAction::RequestPassword(_username) => {
113                // Always respond with password required
114                writer.write_all(AUTH_REQUIRED).await?;
115                Ok((AUTH_REQUIRED.len(), false))
116            }
117            AuthAction::ValidateAndRespond { password } => {
118                // RFC 4643 §2.3.2: AUTHINFO PASS without a prior AUTHINFO USER
119                // must return 482 (commands issued out of sequence).
120                if stored_username.is_none() {
121                    writer.write_all(AUTH_OUT_OF_SEQUENCE).await?;
122                    return Ok((AUTH_OUT_OF_SEQUENCE.len(), false));
123                }
124
125                // Validate credentials
126                let auth_success = stored_username
127                    .is_some_and(|username| self.validate_credentials(username, password));
128
129                let response = if auth_success {
130                    AUTH_ACCEPTED
131                } else {
132                    AUTH_FAILED
133                };
134                writer.write_all(response).await?;
135                Ok((response.len(), auth_success))
136            }
137            AuthAction::UnknownSubcommand => {
138                // RFC 4643 §2.3.1: unrecognized AUTHINFO subcommands must return 501.
139                // Note: if the client is already authenticated, common::handle_auth_command
140                // returns 502 before reaching here.
141                use crate::protocol::AUTH_UNKNOWN_SUBCOMMAND;
142                writer.write_all(AUTH_UNKNOWN_SUBCOMMAND).await?;
143                Ok((AUTH_UNKNOWN_SUBCOMMAND.len(), false))
144            }
145        }
146    }
147
148    /// Get the AUTHINFO USER response
149    #[inline]
150    #[must_use]
151    pub const fn user_response(&self) -> &'static [u8] {
152        AUTH_REQUIRED
153    }
154
155    /// Get the AUTHINFO PASS response
156    #[inline]
157    #[must_use]
158    pub const fn pass_response(&self) -> &'static [u8] {
159        AUTH_ACCEPTED
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn test_handler() -> AuthHandler {
168        AuthHandler::default()
169    }
170
171    mod auth_handler {
172        use super::*;
173
174        #[test]
175        fn test_default() {
176            let handler = AuthHandler::default();
177            assert!(!handler.is_enabled());
178        }
179
180        #[test]
181        fn test_new_with_both_credentials() {
182            let handler =
183                AuthHandler::new(Some("user".to_string()), Some("pass".to_string())).unwrap();
184            assert!(handler.is_enabled());
185        }
186
187        #[test]
188        fn test_new_with_only_username() {
189            let handler = AuthHandler::new(Some("user".to_string()), None).unwrap();
190            assert!(!handler.is_enabled());
191        }
192
193        #[test]
194        fn test_new_with_only_password() {
195            let handler = AuthHandler::new(None, Some("pass".to_string())).unwrap();
196            assert!(!handler.is_enabled());
197        }
198
199        #[test]
200        fn test_new_with_neither() {
201            let handler = AuthHandler::new(None, None).unwrap();
202            assert!(!handler.is_enabled());
203        }
204
205        #[test]
206        fn test_with_users_multiple() {
207            let users = vec![
208                ("alice".to_string(), "secret1".to_string()),
209                ("bob".to_string(), "secret2".to_string()),
210                ("charlie".to_string(), "secret3".to_string()),
211            ];
212            let handler = AuthHandler::with_users(users).unwrap();
213            assert!(handler.is_enabled());
214            assert!(handler.validate_credentials("alice", "secret1"));
215            assert!(handler.validate_credentials("bob", "secret2"));
216            assert!(handler.validate_credentials("charlie", "secret3"));
217            assert!(!handler.validate_credentials("alice", "wrong"));
218            assert!(!handler.validate_credentials("bob", "secret1")); // Wrong password for bob
219            assert!(!handler.validate_credentials("dave", "anything")); // Unknown user
220        }
221
222        #[test]
223        fn test_with_users_empty() {
224            let handler = AuthHandler::with_users(vec![]).unwrap();
225            assert!(!handler.is_enabled());
226            assert!(handler.validate_credentials("anyone", "anything")); // No auth, allow all
227        }
228
229        #[test]
230        fn test_with_users_rejects_empty_username() {
231            let users = vec![
232                ("alice".to_string(), "pass1".to_string()),
233                (String::new(), "pass2".to_string()), // Empty username
234            ];
235            let result = AuthHandler::with_users(users);
236            assert!(result.is_err());
237        }
238
239        #[test]
240        fn test_with_users_rejects_empty_password() {
241            let users = vec![
242                ("alice".to_string(), "pass1".to_string()),
243                ("bob".to_string(), String::new()), // Empty password
244            ];
245            let result = AuthHandler::with_users(users);
246            assert!(result.is_err());
247        }
248
249        #[test]
250        fn test_new_with_empty_username_fails() {
251            let result = AuthHandler::new(Some(String::new()), Some("pass".to_string()));
252            assert!(result.is_err(), "Empty username should return error");
253        }
254
255        #[test]
256        fn test_new_with_empty_password_fails() {
257            let result = AuthHandler::new(Some("user".to_string()), Some(String::new()));
258            assert!(result.is_err(), "Empty password should return error");
259        }
260
261        #[test]
262        fn test_new_with_whitespace_username_fails() {
263            let result = AuthHandler::new(Some("   ".to_string()), Some("pass".to_string()));
264            assert!(
265                result.is_err(),
266                "Whitespace-only username should return error"
267            );
268        }
269
270        #[test]
271        fn test_new_with_whitespace_password_fails() {
272            let result = AuthHandler::new(Some("user".to_string()), Some("   ".to_string()));
273            assert!(
274                result.is_err(),
275                "Whitespace-only password should return error"
276            );
277        }
278
279        #[test]
280        fn test_validate_when_disabled() {
281            let handler = AuthHandler::new(None, None).unwrap();
282            assert!(handler.validate_credentials("any", "thing"));
283            assert!(handler.validate_credentials("", ""));
284            assert!(handler.validate_credentials("foo", "bar"));
285        }
286
287        #[test]
288        fn test_validate_when_enabled() {
289            let handler =
290                AuthHandler::new(Some("alice".to_string()), Some("secret".to_string())).unwrap();
291            assert!(handler.validate_credentials("alice", "secret"));
292            assert!(!handler.validate_credentials("alice", "wrong"));
293            assert!(!handler.validate_credentials("bob", "secret"));
294            assert!(!handler.validate_credentials("bob", "wrong"));
295        }
296
297        #[test]
298        fn test_is_enabled_consistent() {
299            let disabled = AuthHandler::new(None, None).unwrap();
300            assert!(!disabled.is_enabled());
301            assert!(!disabled.is_enabled()); // Call twice to ensure consistency
302
303            let enabled = AuthHandler::new(Some("u".to_string()), Some("p".to_string())).unwrap();
304            assert!(enabled.is_enabled());
305            assert!(enabled.is_enabled()); // Call twice to ensure consistency
306        }
307    }
308
309    #[test]
310    fn test_user_response() {
311        let handler = test_handler();
312        let response = handler.user_response();
313        let response_str = String::from_utf8_lossy(response);
314
315        // Should be 381 Password required
316        assert!(response_str.starts_with("381"));
317        assert!(response_str.contains("Password required") || response_str.contains("password"));
318        assert!(response_str.ends_with("\r\n"));
319    }
320
321    #[test]
322    fn test_pass_response() {
323        let handler = test_handler();
324        let response = handler.pass_response();
325        let response_str = String::from_utf8_lossy(response);
326
327        // Should be 281 Authentication accepted
328        assert!(response_str.starts_with("281"));
329        assert!(response_str.contains("accepted") || response_str.contains("Authentication"));
330        assert!(response_str.ends_with("\r\n"));
331    }
332
333    #[test]
334    fn test_responses_are_static() {
335        // Verify responses are the same each time (static)
336        let handler = test_handler();
337        let response1 = handler.user_response();
338        let response2 = handler.user_response();
339        assert_eq!(response1.as_ptr(), response2.as_ptr());
340
341        let response3 = handler.pass_response();
342        let response4 = handler.pass_response();
343        assert_eq!(response3.as_ptr(), response4.as_ptr());
344    }
345
346    #[test]
347    fn test_responses_are_different() {
348        // User and pass responses should be different
349        let handler = test_handler();
350        let user_resp = handler.user_response();
351        let pass_resp = handler.pass_response();
352        assert_ne!(user_resp, pass_resp);
353    }
354
355    #[test]
356    fn test_responses_are_valid_utf8() {
357        // Ensure responses are valid UTF-8
358        let handler = test_handler();
359        let user_resp = handler.user_response();
360        assert!(std::str::from_utf8(user_resp).is_ok());
361
362        let pass_resp = handler.pass_response();
363        assert!(std::str::from_utf8(pass_resp).is_ok());
364    }
365
366    #[test]
367    fn test_auth_disabled_by_default() {
368        let handler = AuthHandler::default();
369        assert!(!handler.is_enabled());
370        assert!(handler.validate_credentials("any", "thing")); // Should accept anything
371    }
372
373    #[test]
374    fn test_auth_new_none_none() {
375        let handler = AuthHandler::new(None, None).unwrap();
376        assert!(!handler.is_enabled());
377        assert!(handler.validate_credentials("any", "thing"));
378    }
379
380    #[test]
381    fn test_auth_enabled_with_credentials() {
382        let handler =
383            AuthHandler::new(Some("mjc".to_string()), Some("nntp1337".to_string())).unwrap();
384        assert!(handler.is_enabled());
385        assert!(handler.validate_credentials("mjc", "nntp1337"));
386        assert!(!handler.validate_credentials("mjc", "wrong"));
387        assert!(!handler.validate_credentials("wrong", "nntp1337"));
388    }
389
390    #[test]
391    fn test_security_empty_credentials_rejected() {
392        // SECURITY: Empty username must fail
393        let result = AuthHandler::new(Some(String::new()), Some("pass".to_string()));
394        assert!(
395            result.is_err(),
396            "Empty username should be rejected to prevent silent auth bypass"
397        );
398
399        // SECURITY: Empty password must fail
400        let result = AuthHandler::new(Some("user".to_string()), Some(String::new()));
401        assert!(
402            result.is_err(),
403            "Empty password should be rejected to prevent silent auth bypass"
404        );
405
406        // SECURITY: Both empty must fail
407        let result = AuthHandler::new(Some(String::new()), Some(String::new()));
408        assert!(
409            result.is_err(),
410            "Both empty should be rejected to prevent silent auth bypass"
411        );
412    }
413
414    #[test]
415    fn test_security_whitespace_credentials_rejected() {
416        // SECURITY: Whitespace-only username must fail
417        let result = AuthHandler::new(Some("   ".to_string()), Some("pass".to_string()));
418        assert!(
419            result.is_err(),
420            "Whitespace-only username should be rejected"
421        );
422
423        // SECURITY: Whitespace-only password must fail
424        let result = AuthHandler::new(Some("user".to_string()), Some("   ".to_string()));
425        assert!(
426            result.is_err(),
427            "Whitespace-only password should be rejected"
428        );
429    }
430
431    #[test]
432    fn test_security_explicit_config_prevents_silent_bypass() {
433        // This test demonstrates the security fix:
434        // If someone sets credentials in config but they're empty,
435        // we MUST fail rather than silently disable auth.
436        //
437        // Before fix: Empty credentials = auth silently disabled = MASSIVE SECURITY HOLE
438        // After fix: Empty credentials = proxy refuses to start = SAFE
439
440        // Simulate someone setting credentials in config
441        let username_from_config = Some(String::new()); // Typo or misconfiguration
442        let password_from_config = Some("secret".to_string());
443
444        let result = AuthHandler::new(username_from_config, password_from_config);
445
446        assert!(
447            result.is_err(),
448            "Proxy must refuse to start with empty credentials from config. \
449             Silently disabling auth would be a critical security vulnerability!"
450        );
451    }
452}