ntex_basicauth/
utils.rs

1//! Utility functions and helper types
2
3use crate::{Credentials, BasicAuth, BasicAuthConfig, UserValidator, AuthError, AuthResult};
4use ntex::web::{HttpRequest, WebRequest};
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::time::Duration;
8
9#[cfg(feature = "regex")]
10use regex::Regex;
11
12#[cfg(feature = "cache")]
13use crate::cache::CacheConfig;
14
15#[cfg(feature = "regex")]
16use std::sync::OnceLock;
17
18#[cfg(feature = "regex")]
19static REGEX_CACHE: OnceLock<dashmap::DashMap<String, Regex>> = OnceLock::new();
20
21/// Extract authenticated user credentials from request
22pub fn extract_credentials(req: &HttpRequest) -> Option<Credentials> {
23    req.extensions().get::<Credentials>().cloned()
24}
25
26/// Extract authenticated user credentials from WebRequest
27pub fn extract_credentials_web<T>(req: &WebRequest<T>) -> Option<Credentials> {
28    req.extensions().get::<Credentials>().cloned()
29}
30
31/// Get authenticated username from request
32pub fn get_username(req: &HttpRequest) -> Option<String> {
33    extract_credentials(req).map(|creds| creds.username.clone())
34}
35
36/// Check if current user matches a specific username
37pub fn is_user(req: &HttpRequest, username: &str) -> bool {
38    get_username(req).is_some_and(|user| user == username)
39}
40
41/// Path filter for conditional authentication
42#[derive(Debug, Clone)]
43pub struct PathFilter {
44    patterns: Vec<PathPattern>,
45}
46
47#[derive(Debug, Clone)]
48enum PathPattern {
49    Exact(String),
50    Prefix(String),
51    Suffix(String),
52    #[cfg(feature = "regex")]
53    Regex(Regex),
54}
55
56impl PathFilter {
57    /// Create a new PathFilter instance
58    pub fn new() -> Self {
59        Self {
60            patterns: Vec::new(),
61        }
62    }
63
64    /// Skip authentication for exact path
65    pub fn skip_exact<P: Into<String>>(mut self, path: P) -> Self {
66        self.patterns.push(PathPattern::Exact(path.into()));
67        self
68    }
69
70    /// Skip authentication for paths with prefix
71    pub fn skip_prefix<P: Into<String>>(mut self, prefix: P) -> Self {
72        self.patterns.push(PathPattern::Prefix(prefix.into()));
73        self
74    }
75
76    /// Skip authentication for paths with suffix
77    pub fn skip_suffix<P: Into<String>>(mut self, suffix: P) -> Self {
78        self.patterns.push(PathPattern::Suffix(suffix.into()));
79        self
80    }
81
82    /// Skip authentication for paths matching regex pattern
83    /// Requires "regex" feature
84    #[cfg(feature = "regex")]
85    pub fn skip_regex<P: AsRef<str>>(mut self, pattern: P) -> Result<Self, regex::Error> {
86        let regex = Self::get_cached_regex(pattern.as_ref())?;
87        self.patterns.push(PathPattern::Regex(regex));
88        Ok(self)
89    }
90
91    /// Get cached regex pattern for better performance
92    #[cfg(feature = "regex")]
93    fn get_cached_regex(pattern: &str) -> Result<Regex, regex::Error> {
94        let cache = REGEX_CACHE.get_or_init(|| dashmap::DashMap::new());
95        
96        if let Some(regex) = cache.get(pattern) {
97            Ok(regex.clone())
98        } else {
99            let regex = Regex::new(pattern)?;
100            cache.insert(pattern.to_string(), regex.clone());
101            Ok(regex)
102        }
103    }
104
105    /// Skip authentication for regex pattern (feature disabled version)
106    #[cfg(not(feature = "regex"))]
107    pub fn skip_regex<P: Into<String>>(self, _pattern: P) -> Result<Self, AuthError> {
108        Err(AuthError::ConfigError("regex feature not enabled. Please use: features = [\"regex\"]".to_string()))
109    }
110
111    /// Skip authentication for multiple exact paths
112    pub fn skip_paths<I, P>(mut self, paths: I) -> Self 
113    where 
114        I: IntoIterator<Item = P>,
115        P: Into<String>,
116    {
117        for path in paths {
118            self.patterns.push(PathPattern::Exact(path.into()));
119        }
120        self
121    }
122
123    /// Skip authentication for multiple prefixes
124    pub fn skip_prefixes<I, P>(mut self, prefixes: I) -> Self 
125    where 
126        I: IntoIterator<Item = P>,
127        P: Into<String>,
128    {
129        for prefix in prefixes {
130            self.patterns.push(PathPattern::Prefix(prefix.into()));
131        }
132        self
133    }
134
135    /// Skip authentication for multiple suffixes
136    pub fn skip_suffixes<I, P>(mut self, suffixes: I) -> Self 
137    where 
138        I: IntoIterator<Item = P>,
139        P: Into<String>,
140    {
141        for suffix in suffixes {
142            self.patterns.push(PathPattern::Suffix(suffix.into()));
143        }
144        self
145    }
146
147    /// Check if path should skip authentication
148    pub fn should_skip(&self, path: &str) -> bool {
149        self.patterns.iter().any(|pattern| match pattern {
150            PathPattern::Exact(exact) => path == exact,
151            PathPattern::Prefix(prefix) => path.starts_with(prefix),
152            PathPattern::Suffix(suffix) => path.ends_with(suffix),
153            #[cfg(feature = "regex")]
154            PathPattern::Regex(regex) => regex.is_match(path),
155        })
156    }
157
158    /// Get number of patterns
159    pub fn pattern_count(&self) -> usize {
160        self.patterns.len()
161    }
162
163    /// Check if filter is empty
164    pub fn is_empty(&self) -> bool {
165        self.patterns.is_empty()
166    }
167
168    /// Get all exact match paths
169    pub fn exact_paths(&self) -> Vec<&str> {
170        self.patterns.iter()
171            .filter_map(|pattern| match pattern {
172                PathPattern::Exact(path) => Some(path.as_str()),
173                _ => None,
174            })
175            .collect()
176    }
177
178    /// Get all prefixes
179    pub fn prefixes(&self) -> Vec<&str> {
180        self.patterns.iter()
181            .filter_map(|pattern| match pattern {
182                PathPattern::Prefix(prefix) => Some(prefix.as_str()),
183                _ => None,
184            })
185            .collect()
186    }
187
188    /// Clear all patterns
189    pub fn clear(mut self) -> Self {
190        self.patterns.clear();
191        self
192    }
193
194    /// Remove a specific exact path pattern
195    pub fn remove_exact_path(mut self, path: &str) -> Self {
196        self.patterns.retain(|pattern| {
197            !matches!(pattern, PathPattern::Exact(p) if p == path)
198        });
199        self
200    }
201}
202
203impl Default for PathFilter {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209/// Builder for BasicAuth, provides extra convenience methods
210pub struct BasicAuthBuilder {
211    users: Option<HashMap<String, String>>,
212    validator: Option<Arc<dyn UserValidator>>,
213    realm: Option<String>,
214    #[cfg(feature = "cache")]
215    cache_config: Option<CacheConfig>,
216    path_filter: Option<PathFilter>,
217    max_header_size: Option<usize>,
218    log_failures: bool,
219    case_sensitive: bool,
220    // New enhanced configuration fields
221    max_concurrent_validations: Option<usize>,
222    validation_timeout: Option<Duration>,
223    rate_limit_per_ip: Option<(usize, Duration)>,
224    enable_metrics: bool,
225    log_usernames_in_production: bool,
226}
227
228impl BasicAuthBuilder {
229    /// Create a new BasicAuthBuilder instance
230    pub fn new() -> Self {
231        Self {
232            users: None,
233            validator: None,
234            realm: None,
235            #[cfg(feature = "cache")]
236            cache_config: None,
237            path_filter: None,
238            max_header_size: None,
239            log_failures: false,
240            case_sensitive: true,
241            max_concurrent_validations: None,
242            validation_timeout: None,
243            rate_limit_per_ip: None,
244            enable_metrics: true,
245            log_usernames_in_production: false,
246        }
247    }
248
249    /// Add a single user
250    pub fn user<U: Into<String>, P: Into<String>>(mut self, username: U, password: P) -> Self {
251        let users = self.users.get_or_insert_with(HashMap::new);
252        users.insert(username.into(), password.into());
253        self
254    }
255
256    /// Add multiple users from HashMap
257    pub fn users(mut self, users: HashMap<String, String>) -> Self {
258        match &mut self.users {
259            Some(existing) => existing.extend(users),
260            None => self.users = Some(users),
261        }
262        self
263    }
264
265    /// Add users from iterator
266    pub fn users_from_iter<I, U, P>(mut self, users: I) -> Self 
267    where
268        I: IntoIterator<Item = (U, P)>,
269        U: Into<String>,
270        P: Into<String>,
271    {
272        let mut users_map = self.users.get_or_insert_with(HashMap::new).clone();
273        for (username, password) in users {
274            users_map.insert(username.into(), password.into());
275        }
276        self.users = Some(users_map);
277        self
278    }
279
280    /// Load users from file (format: username:password, one per line)
281    pub fn users_from_file<P: AsRef<std::path::Path>>(mut self, path: P) -> AuthResult<Self> {
282        let content = std::fs::read_to_string(path)
283            .map_err(|e| AuthError::ConfigError(format!("Failed to read user file: {}", e)))?;
284        
285        let mut users_map = self.users.get_or_insert_with(HashMap::new).clone();
286        
287        for (line_num, line) in content.lines().enumerate() {
288            let line = line.trim();
289            if line.is_empty() || line.starts_with('#') {
290                continue; // Skip empty lines and comments
291            }
292            
293            let colon_pos = line.find(':')
294                .ok_or_else(|| AuthError::ConfigError(
295                    format!("User file line {} format error: missing colon separator", line_num + 1)
296                ))?;
297            
298            let username = line[..colon_pos].trim().to_string();
299            let password = line[colon_pos + 1..].trim().to_string();
300            
301            // Allow empty usernames per RFC 7617
302            if !is_valid_username(&username) {
303                return Err(AuthError::ConfigError(
304                    format!("User file line {} format error: invalid username format", line_num + 1)
305                ));
306            }
307            
308            dbg!("Loaded user: {}   {}", &username, &password);
309            users_map.insert(username, password);
310        }
311        self.users = Some(users_map);
312        
313        Ok(self)
314    }
315
316    /// Set custom validator
317    pub fn validator(mut self, validator: Arc<dyn UserValidator>) -> Self {
318        self.validator = Some(validator);
319        self
320    }
321
322    /// Set authentication realm
323    pub fn realm<R: Into<String>>(mut self, realm: R) -> Self {
324        self.realm = Some(realm.into());
325        self
326    }
327
328    /// Set username case sensitivity
329    pub fn case_sensitive(mut self, sensitive: bool) -> Self {
330        self.case_sensitive = sensitive;
331        self
332    }
333
334    /// Enable authentication cache
335    #[cfg(feature = "cache")]
336    pub fn with_cache(mut self, config: CacheConfig) -> Self {
337        self.cache_config = Some(config);
338        self
339    }
340
341    /// Disable authentication cache
342    #[cfg(feature = "cache")]
343    pub fn disable_cache(mut self) -> Self {
344        self.cache_config = None;
345        self
346    }
347
348    /// Set cache TTL (seconds)
349    #[cfg(feature = "cache")]
350    pub fn cache_ttl_seconds(mut self, seconds: u64) -> Self {
351        let config = self.cache_config.take().unwrap_or_default();
352        self.cache_config = Some(config.ttl_seconds(seconds));
353        self
354    }
355
356    /// Set cache TTL (minutes, convenience)
357    #[cfg(feature = "cache")]
358    pub fn cache_ttl_minutes(self, minutes: u64) -> Self {
359        self.cache_ttl_seconds(minutes * 60)
360    }
361
362    /// Set cache TTL (hours, convenience)
363    #[cfg(feature = "cache")]
364    pub fn cache_ttl_hours(self, hours: u64) -> Self {
365        self.cache_ttl_seconds(hours * 3600)
366    }
367
368    /// Set cache size limit
369    #[cfg(feature = "cache")]
370    pub fn cache_size_limit(mut self, limit: usize) -> Self {
371        let config = self.cache_config.take().unwrap_or_default();
372        self.cache_config = Some(config.max_size(limit));
373        self
374    }
375
376    /// Set path filter
377    pub fn path_filter(mut self, filter: PathFilter) -> Self {
378        self.path_filter = Some(filter);
379        self
380    }
381
382    /// Configure path filter (builder pattern)
383    pub fn configure_paths<F>(mut self, configure: F) -> Self 
384    where 
385        F: FnOnce(PathFilter) -> PathFilter,
386    {
387        let filter = self.path_filter.take().unwrap_or_default();
388        self.path_filter = Some(configure(filter));
389        self
390    }
391
392    /// Add skip paths (convenience)
393    pub fn skip_paths<I, P>(mut self, paths: I) -> Self 
394    where 
395        I: IntoIterator<Item = P>,
396        P: Into<String>,
397    {
398        let mut filter = self.path_filter.take().unwrap_or_default();
399        filter = filter.skip_paths(paths);
400        self.path_filter = Some(filter);
401        self
402    }
403
404    /// Set request header size limit
405    pub fn max_header_size(mut self, size: usize) -> Self {
406        self.max_header_size = Some(size);
407        self
408    }
409
410    /// Enable authentication failure logging
411    pub fn log_failures(mut self, enabled: bool) -> Self {
412        self.log_failures = enabled;
413        self
414    }
415
416    /// Set maximum concurrent validations
417    pub fn max_concurrent_validations(mut self, max: usize) -> Self {
418        self.max_concurrent_validations = Some(max);
419        self
420    }
421
422    /// Set validation timeout
423    pub fn validation_timeout(mut self, timeout: Duration) -> Self {
424        self.validation_timeout = Some(timeout);
425        self
426    }
427
428    /// Set rate limiting per IP
429    pub fn rate_limit_per_ip(mut self, max_requests: usize, window: Duration) -> Self {
430        self.rate_limit_per_ip = Some((max_requests, window));
431        self
432    }
433
434    /// Enable or disable metrics collection
435    pub fn enable_metrics(mut self, enabled: bool) -> Self {
436        self.enable_metrics = enabled;
437        self
438    }
439
440    /// Enable or disable logging usernames in production (security risk)
441    pub fn log_usernames_in_production(mut self, enabled: bool) -> Self {
442        self.log_usernames_in_production = enabled;
443        self
444    }
445
446    /// Build BasicAuth instance
447    pub fn build(self) -> AuthResult<BasicAuth> {
448        let validator = if let Some(validator) = self.validator {
449            validator
450        } else if let Some(users) = self.users {
451            use crate::auth::StaticUserValidator;
452            Arc::new(if self.case_sensitive {
453                StaticUserValidator::from_map(users)
454            } else {
455                StaticUserValidator::from_map_case_insensitive(users)
456            })
457        } else {
458            return Err(AuthError::ConfigError(
459                "A validator or user list must be provided".to_string()
460            ));
461        };
462
463        let mut config = BasicAuthConfig::new(validator);
464        
465        if let Some(realm) = self.realm {
466            config = config.realm(realm);
467        }
468        
469        #[cfg(feature = "cache")]
470        {
471            if let Some(cache_config) = self.cache_config {
472                config = config.with_cache(cache_config)?;
473            }
474        }
475
476        if let Some(filter) = self.path_filter {
477            config = config.path_filter(filter);
478        }
479
480        if let Some(size) = self.max_header_size {
481            config = config.max_header_size(size);
482        }
483
484        config = config.log_failures(self.log_failures);
485
486        // Apply new enhanced configuration options
487        if let Some(max_concurrent) = self.max_concurrent_validations {
488            config = config.max_concurrent_validations(max_concurrent);
489        }
490
491        if let Some(timeout) = self.validation_timeout {
492            config = config.validation_timeout(timeout);
493        }
494
495        if let Some((max_requests, window)) = self.rate_limit_per_ip {
496            config = config.rate_limit_per_ip(max_requests, window);
497        }
498
499        config = config.enable_metrics(self.enable_metrics);
500        config = config.log_usernames_in_production(self.log_usernames_in_production);
501
502        BasicAuth::new(config)
503    }
504
505    /// Build and wrap error handling
506    pub fn try_build(self) -> AuthResult<BasicAuth> {
507        self.build()
508    }
509}
510
511impl Default for BasicAuthBuilder {
512    fn default() -> Self {
513        Self::new()
514    }
515}
516
517/// Convenience macro for creating PathFilter
518#[macro_export]
519macro_rules! path_filter {
520    // Only exact match
521    (exact: [$($path:expr),* $(,)?]) => {
522        {
523            let mut filter = $crate::PathFilter::new();
524            $(
525                filter = filter.skip_exact($path);
526            )*
527            filter
528        }
529    };
530    
531    // Only prefix match
532    (prefix: [$($prefix:expr),* $(,)?]) => {
533        {
534            let mut filter = $crate::PathFilter::new();
535            $(
536                filter = filter.skip_prefix($prefix);
537            )*
538            filter
539        }
540    };
541    
542    // Only suffix match
543    (suffix: [$($suffix:expr),* $(,)?]) => {
544        {
545            let mut filter = $crate::PathFilter::new();
546            $(
547                filter = filter.skip_suffix($suffix);
548            )*
549            filter
550        }
551    };
552    
553    // Mixed mode
554    (
555        $(exact: [$($exact:expr),* $(,)?])?
556        $(prefix: [$($prefix:expr),* $(,)?])?
557        $(suffix: [$($suffix:expr),* $(,)?])?
558        $(regex: [$($regex:expr),* $(,)?])?
559    ) => {
560        {
561            let mut filter = $crate::PathFilter::new();
562            
563            $($(
564                filter = filter.skip_exact($exact);
565            )*)?
566            
567            $($(
568                filter = filter.skip_prefix($prefix);
569            )*)?
570            
571            $($(
572                filter = filter.skip_suffix($suffix);
573            )*)?
574            
575            #[cfg(feature = "regex")]
576            $($(
577                filter = filter.skip_regex($regex).expect("Invalid regex pattern");
578            )*)?
579            
580            filter
581        }
582    };
583}
584
585/// Validate if username format is valid
586pub fn is_valid_username(username: &str) -> bool {
587    !username.contains(':') && 
588    !username.contains('\n') &&
589    !username.contains('\r') &&
590    username.len() <= 255 && // Reasonable length limit
591    username.chars().all(|c| c.is_ascii_graphic() || c == ' ')
592}
593
594/// Create a PathFilter with common skip paths
595pub(crate) fn common_skip_paths() -> PathFilter {
596    PathFilter::new()
597        .skip_paths([
598            "/health",
599            "/healthcheck", 
600            "/ping",
601            "/status",
602            "/metrics",
603            "/favicon.ico"
604        ])
605        .skip_prefixes([
606            "/static/",
607            "/assets/",
608            "/public/",
609            "/.well-known/"
610        ])
611        .skip_suffixes([
612            ".css",
613            ".js",
614            ".png",
615            ".jpg",
616            ".jpeg",
617            ".gif",
618            ".ico",
619            ".svg",
620            ".woff",
621            ".woff2",
622            ".ttf",
623            ".eot"
624        ])
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_path_filter_comprehensive() {
633        let filter = PathFilter::new()
634            .skip_exact("/health")
635            .skip_prefix("/public/")
636            .skip_suffix(".css")
637            .skip_paths(["/api/status", "/metrics"]);
638
639        assert!(filter.should_skip("/health"));
640        assert!(filter.should_skip("/public/images/logo.png"));
641        assert!(filter.should_skip("/assets/style.css"));
642        assert!(filter.should_skip("/api/status"));
643        assert!(filter.should_skip("/metrics"));
644        assert!(!filter.should_skip("/api/users"));
645        assert!(!filter.should_skip("/healthcheck"));
646    }
647
648    #[cfg(feature = "regex")]
649    #[test]
650    fn test_regex_path_filter() {
651        let filter = PathFilter::new()
652            .skip_regex(r"^/api/v\d+/public/.*$")
653            .expect("Valid regex");
654
655        assert!(filter.should_skip("/api/v1/public/data"));
656        assert!(filter.should_skip("/api/v2/public/files"));
657        assert!(!filter.should_skip("/api/v1/private/data"));
658        assert!(!filter.should_skip("/api/public/data"));
659    }
660
661    #[test]
662    fn test_builder_comprehensive() {
663        let auth = BasicAuthBuilder::new()
664            .user("admin", "secret")
665            .user("user", "password")
666            .users_from_iter([("guest", "guest123")])
667            .realm("Test Application")
668            .skip_paths(["/health", "/metrics"])
669            .log_failures(true)
670            .case_sensitive(false)
671            .max_header_size(4096)
672            .build()
673            .expect("Valid configuration");
674
675        assert_eq!(auth.config.realm, "Test Application");
676        assert_eq!(auth.config.max_header_size, 4096);
677        assert!(auth.config.log_failures);
678    }
679
680    #[test]
681    fn test_common_skip_paths() {
682        let filter = common_skip_paths();
683        
684        assert!(filter.should_skip("/health"));
685        assert!(filter.should_skip("/static/css/main.css"));
686        assert!(filter.should_skip("/favicon.ico"));
687        assert!(filter.should_skip("/public/images/logo.png"));
688        assert!(filter.should_skip("/.well-known/acme-challenge/test"));
689        assert!(!filter.should_skip("/api/users"));
690    }
691
692    #[test]
693    fn test_username_validation() {
694        assert!(is_valid_username("admin"));
695        assert!(is_valid_username("user123"));
696        assert!(is_valid_username("test user")); // contains space
697        
698        // Now allows empty username per RFC 7617
699        assert!(is_valid_username(""));
700        assert!(!is_valid_username("user:name")); // contains colon
701        assert!(!is_valid_username("user\nname")); // contains newline
702        assert!(!is_valid_username(&"a".repeat(256))); // too long
703    }
704
705    #[test]
706    fn test_builder_from_file() -> std::io::Result<()> {
707        use std::io::Write;
708        
709        // Create temporary file
710        let mut temp_file = tempfile::NamedTempFile::new()?;
711        writeln!(temp_file, "# This is a comment")?;
712        writeln!(temp_file, "admin:secret")?;
713        writeln!(temp_file, "user:password:with:colons")?;
714        writeln!(temp_file, "")?; // empty line
715        writeln!(temp_file, "guest:guest123")?;
716        
717        let builder = BasicAuthBuilder::new()
718            .users_from_file(temp_file.path())
719            .expect("Failed to load users from file");
720        
721        let auth = builder.build().expect("Failed to build authentication");
722        
723        // Verify users are loaded correctly
724        let validator = auth.config.validator.as_ref();
725        dbg!("User validator: {:?}", validator);
726        assert_eq!(validator.user_count(), 3);
727        
728        Ok(())
729    }
730
731    #[test]
732    fn test_path_filter_modification() {
733        let filter = PathFilter::new()
734            .skip_exact("/health")
735            .skip_exact("/status");
736        
737        assert_eq!(filter.pattern_count(), 2);
738        
739        let filter = filter.remove_exact_path("/health");
740        assert_eq!(filter.pattern_count(), 1);
741        
742        let filter = filter.clear();
743        assert_eq!(filter.pattern_count(), 0);
744        assert!(filter.is_empty());
745    }
746}