1use crate::{AuthError, AuthResult, BasicAuth, BasicAuthConfig, Credentials, UserValidator};
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
21pub fn extract_credentials(req: &HttpRequest) -> Option<Credentials> {
23 req.extensions().get::<Credentials>().cloned()
24}
25
26pub fn extract_credentials_web<T>(req: &WebRequest<T>) -> Option<Credentials> {
28 req.extensions().get::<Credentials>().cloned()
29}
30
31pub fn get_username(req: &HttpRequest) -> Option<String> {
33 extract_credentials(req).map(|creds| creds.username.clone())
34}
35
36pub fn is_user(req: &HttpRequest, username: &str) -> bool {
38 get_username(req).is_some_and(|user| user == username)
39}
40
41#[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 pub fn new() -> Self {
59 Self {
60 patterns: Vec::new(),
61 }
62 }
63
64 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 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 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 #[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 #[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 #[cfg(not(feature = "regex"))]
107 pub fn skip_regex<P: Into<String>>(self, _pattern: P) -> Result<Self, AuthError> {
108 Err(AuthError::ConfigError(
109 "regex feature not enabled. Please use: features = [\"regex\"]".to_string(),
110 ))
111 }
112
113 pub fn skip_paths<I, P>(mut self, paths: I) -> Self
115 where
116 I: IntoIterator<Item = P>,
117 P: Into<String>,
118 {
119 for path in paths {
120 self.patterns.push(PathPattern::Exact(path.into()));
121 }
122 self
123 }
124
125 pub fn skip_prefixes<I, P>(mut self, prefixes: I) -> Self
127 where
128 I: IntoIterator<Item = P>,
129 P: Into<String>,
130 {
131 for prefix in prefixes {
132 self.patterns.push(PathPattern::Prefix(prefix.into()));
133 }
134 self
135 }
136
137 pub fn skip_suffixes<I, P>(mut self, suffixes: I) -> Self
139 where
140 I: IntoIterator<Item = P>,
141 P: Into<String>,
142 {
143 for suffix in suffixes {
144 self.patterns.push(PathPattern::Suffix(suffix.into()));
145 }
146 self
147 }
148
149 pub fn should_skip(&self, path: &str) -> bool {
151 self.patterns.iter().any(|pattern| match pattern {
152 PathPattern::Exact(exact) => path == exact,
153 PathPattern::Prefix(prefix) => path.starts_with(prefix),
154 PathPattern::Suffix(suffix) => path.ends_with(suffix),
155 #[cfg(feature = "regex")]
156 PathPattern::Regex(regex) => regex.is_match(path),
157 })
158 }
159
160 pub fn pattern_count(&self) -> usize {
162 self.patterns.len()
163 }
164
165 pub fn is_empty(&self) -> bool {
167 self.patterns.is_empty()
168 }
169
170 pub fn exact_paths(&self) -> Vec<&str> {
172 self.patterns
173 .iter()
174 .filter_map(|pattern| match pattern {
175 PathPattern::Exact(path) => Some(path.as_str()),
176 _ => None,
177 })
178 .collect()
179 }
180
181 pub fn prefixes(&self) -> Vec<&str> {
183 self.patterns
184 .iter()
185 .filter_map(|pattern| match pattern {
186 PathPattern::Prefix(prefix) => Some(prefix.as_str()),
187 _ => None,
188 })
189 .collect()
190 }
191
192 pub fn clear(mut self) -> Self {
194 self.patterns.clear();
195 self
196 }
197
198 pub fn remove_exact_path(mut self, path: &str) -> Self {
200 self.patterns
201 .retain(|pattern| !matches!(pattern, PathPattern::Exact(p) if p == path));
202 self
203 }
204}
205
206impl Default for PathFilter {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212pub struct BasicAuthBuilder {
214 users: Option<HashMap<String, String>>,
215 validator: Option<Arc<dyn UserValidator>>,
216 realm: Option<String>,
217 #[cfg(feature = "cache")]
218 cache_config: Option<CacheConfig>,
219 path_filter: Option<PathFilter>,
220 max_header_size: Option<usize>,
221 log_failures: bool,
222 case_sensitive: bool,
223 max_concurrent_validations: Option<usize>,
225 validation_timeout: Option<Duration>,
226 rate_limit_per_ip: Option<(usize, Duration)>,
227 client_ip_header: Option<String>,
228 enable_metrics: bool,
229 log_usernames_in_production: bool,
230}
231
232impl BasicAuthBuilder {
233 pub fn new() -> Self {
235 Self {
236 users: None,
237 validator: None,
238 realm: None,
239 #[cfg(feature = "cache")]
240 cache_config: None,
241 path_filter: None,
242 max_header_size: None,
243 log_failures: false,
244 case_sensitive: true,
245 max_concurrent_validations: None,
246 validation_timeout: None,
247 rate_limit_per_ip: None,
248 client_ip_header: None,
249 enable_metrics: true,
250 log_usernames_in_production: false,
251 }
252 }
253
254 pub fn user<U: Into<String>, P: Into<String>>(mut self, username: U, password: P) -> Self {
256 let users = self.users.get_or_insert_with(HashMap::new);
257 users.insert(username.into(), password.into());
258 self
259 }
260
261 pub fn users(mut self, users: HashMap<String, String>) -> Self {
263 match &mut self.users {
264 Some(existing) => existing.extend(users),
265 None => self.users = Some(users),
266 }
267 self
268 }
269
270 pub fn users_from_iter<I, U, P>(mut self, users: I) -> Self
272 where
273 I: IntoIterator<Item = (U, P)>,
274 U: Into<String>,
275 P: Into<String>,
276 {
277 let users_map = self.users.get_or_insert_with(HashMap::new);
278 for (username, password) in users {
279 users_map.insert(username.into(), password.into());
280 }
281 self
282 }
283
284 pub fn users_from_file<P: AsRef<std::path::Path>>(mut self, path: P) -> AuthResult<Self> {
286 let content = std::fs::read_to_string(path)
287 .map_err(|e| AuthError::ConfigError(format!("Failed to read user file: {}", e)))?;
288
289 let users_map = self.users.get_or_insert_with(HashMap::new);
290
291 for (line_num, line) in content.lines().enumerate() {
292 let line = line.trim();
293 if line.is_empty() || line.starts_with('#') {
294 continue; }
296
297 let colon_pos = line.find(':').ok_or_else(|| {
298 AuthError::ConfigError(format!(
299 "User file line {} format error: missing colon separator",
300 line_num + 1
301 ))
302 })?;
303
304 let username = line[..colon_pos].trim().to_string();
305 let password = line[colon_pos + 1..].trim().to_string();
306
307 if !is_valid_username(&username) {
309 return Err(AuthError::ConfigError(format!(
310 "User file line {} format error: invalid username format",
311 line_num + 1
312 )));
313 }
314
315 users_map.insert(username, password);
316 }
317
318 Ok(self)
319 }
320
321 pub fn validator(mut self, validator: Arc<dyn UserValidator>) -> Self {
323 self.validator = Some(validator);
324 self
325 }
326
327 pub fn realm<R: Into<String>>(mut self, realm: R) -> Self {
329 self.realm = Some(realm.into());
330 self
331 }
332
333 pub fn case_sensitive(mut self, sensitive: bool) -> Self {
335 self.case_sensitive = sensitive;
336 self
337 }
338
339 #[cfg(feature = "cache")]
341 pub fn with_cache(mut self, config: CacheConfig) -> Self {
342 self.cache_config = Some(config);
343 self
344 }
345
346 #[cfg(feature = "cache")]
348 pub fn disable_cache(mut self) -> Self {
349 self.cache_config = None;
350 self
351 }
352
353 #[cfg(feature = "cache")]
355 pub fn cache_ttl_seconds(mut self, seconds: u64) -> Self {
356 let config = self.cache_config.take().unwrap_or_default();
357 self.cache_config = Some(config.ttl_seconds(seconds));
358 self
359 }
360
361 #[cfg(feature = "cache")]
363 pub fn cache_ttl_minutes(self, minutes: u64) -> Self {
364 self.cache_ttl_seconds(minutes * 60)
365 }
366
367 #[cfg(feature = "cache")]
369 pub fn cache_ttl_hours(self, hours: u64) -> Self {
370 self.cache_ttl_seconds(hours * 3600)
371 }
372
373 #[cfg(feature = "cache")]
375 pub fn cache_size_limit(mut self, limit: usize) -> Self {
376 let config = self.cache_config.take().unwrap_or_default();
377 self.cache_config = Some(config.max_size(limit));
378 self
379 }
380
381 pub fn path_filter(mut self, filter: PathFilter) -> Self {
383 self.path_filter = Some(filter);
384 self
385 }
386
387 pub fn configure_paths<F>(mut self, configure: F) -> Self
389 where
390 F: FnOnce(PathFilter) -> PathFilter,
391 {
392 let filter = self.path_filter.take().unwrap_or_default();
393 self.path_filter = Some(configure(filter));
394 self
395 }
396
397 pub fn skip_paths<I, P>(mut self, paths: I) -> Self
399 where
400 I: IntoIterator<Item = P>,
401 P: Into<String>,
402 {
403 let mut filter = self.path_filter.take().unwrap_or_default();
404 filter = filter.skip_paths(paths);
405 self.path_filter = Some(filter);
406 self
407 }
408
409 pub fn max_header_size(mut self, size: usize) -> Self {
411 self.max_header_size = Some(size);
412 self
413 }
414
415 pub fn log_failures(mut self, enabled: bool) -> Self {
417 self.log_failures = enabled;
418 self
419 }
420
421 pub fn max_concurrent_validations(mut self, max: usize) -> Self {
423 self.max_concurrent_validations = Some(max);
424 self
425 }
426
427 pub fn validation_timeout(mut self, timeout: Duration) -> Self {
429 self.validation_timeout = Some(timeout);
430 self
431 }
432
433 pub fn rate_limit_per_ip(mut self, max_requests: usize, window: Duration) -> Self {
435 self.rate_limit_per_ip = Some((max_requests, window));
436 self
437 }
438
439 pub fn client_ip_header(mut self, header: impl Into<String>) -> Self {
443 self.client_ip_header = Some(header.into());
444 self
445 }
446
447 pub fn enable_metrics(mut self, enabled: bool) -> Self {
449 self.enable_metrics = enabled;
450 self
451 }
452
453 pub fn log_usernames_in_production(mut self, enabled: bool) -> Self {
455 self.log_usernames_in_production = enabled;
456 self
457 }
458
459 pub fn build(self) -> AuthResult<BasicAuth> {
461 let validator = if let Some(validator) = self.validator {
462 validator
463 } else if let Some(users) = self.users {
464 use crate::auth::StaticUserValidator;
465 Arc::new(if self.case_sensitive {
466 StaticUserValidator::from_map(users)
467 } else {
468 StaticUserValidator::from_map_case_insensitive(users)
469 })
470 } else {
471 return Err(AuthError::ConfigError(
472 "A validator or user list must be provided".to_string(),
473 ));
474 };
475
476 let mut config = BasicAuthConfig::new(validator);
477
478 if let Some(realm) = self.realm {
479 config = config.realm(realm);
480 }
481
482 #[cfg(feature = "cache")]
483 {
484 if let Some(cache_config) = self.cache_config {
485 config = config.with_cache(cache_config)?;
486 }
487 }
488
489 if let Some(filter) = self.path_filter {
490 config = config.path_filter(filter);
491 }
492
493 if let Some(size) = self.max_header_size {
494 config = config.max_header_size(size);
495 }
496
497 config = config.log_failures(self.log_failures);
498
499 if let Some(max_concurrent) = self.max_concurrent_validations {
501 config = config.max_concurrent_validations(max_concurrent);
502 }
503
504 if let Some(timeout) = self.validation_timeout {
505 config = config.validation_timeout(timeout);
506 }
507
508 if let Some((max_requests, window)) = self.rate_limit_per_ip {
509 config = config.rate_limit_per_ip(max_requests, window);
510 }
511
512 if let Some(header) = self.client_ip_header {
513 config = config.client_ip_header(header);
514 }
515
516 config = config.enable_metrics(self.enable_metrics);
517 config = config.log_usernames_in_production(self.log_usernames_in_production);
518
519 BasicAuth::new(config)
520 }
521
522 pub fn try_build(self) -> AuthResult<BasicAuth> {
524 self.build()
525 }
526}
527
528impl Default for BasicAuthBuilder {
529 fn default() -> Self {
530 Self::new()
531 }
532}
533
534#[macro_export]
536macro_rules! path_filter {
537 (exact: [$($path:expr),* $(,)?]) => {
539 {
540 let mut filter = $crate::PathFilter::new();
541 $(
542 filter = filter.skip_exact($path);
543 )*
544 filter
545 }
546 };
547
548 (prefix: [$($prefix:expr),* $(,)?]) => {
550 {
551 let mut filter = $crate::PathFilter::new();
552 $(
553 filter = filter.skip_prefix($prefix);
554 )*
555 filter
556 }
557 };
558
559 (suffix: [$($suffix:expr),* $(,)?]) => {
561 {
562 let mut filter = $crate::PathFilter::new();
563 $(
564 filter = filter.skip_suffix($suffix);
565 )*
566 filter
567 }
568 };
569
570 (
572 $(exact: [$($exact:expr),* $(,)?])?
573 $(prefix: [$($prefix:expr),* $(,)?])?
574 $(suffix: [$($suffix:expr),* $(,)?])?
575 $(regex: [$($regex:expr),* $(,)?])?
576 ) => {
577 {
578 let mut filter = $crate::PathFilter::new();
579
580 $($(
581 filter = filter.skip_exact($exact);
582 )*)?
583
584 $($(
585 filter = filter.skip_prefix($prefix);
586 )*)?
587
588 $($(
589 filter = filter.skip_suffix($suffix);
590 )*)?
591
592 #[cfg(feature = "regex")]
593 $($(
594 filter = filter.skip_regex($regex).expect("Invalid regex pattern");
595 )*)?
596
597 filter
598 }
599 };
600}
601
602pub fn is_valid_username(username: &str) -> bool {
604 !username.contains(':') && !username.contains('\n') &&
605 !username.contains('\r') &&
606 username.len() <= 255 && username.chars().all(|c| c.is_ascii_graphic() || c == ' ')
608}
609
610pub fn common_skip_paths() -> PathFilter {
619 PathFilter::new()
620 .skip_paths(["/health", "/healthcheck", "/ping", "/favicon.ico"])
621 .skip_prefixes(["/static/", "/assets/", "/public/", "/.well-known/"])
622 .skip_suffixes([
623 ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff", ".woff2",
624 ".ttf", ".eot",
625 ])
626}
627
628pub fn observability_skip_paths() -> PathFilter {
635 PathFilter::new().skip_paths(["/metrics", "/status"])
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn test_path_filter_comprehensive() {
644 let filter = PathFilter::new()
645 .skip_exact("/health")
646 .skip_prefix("/public/")
647 .skip_suffix(".css")
648 .skip_paths(["/api/status", "/metrics"]);
649
650 assert!(filter.should_skip("/health"));
651 assert!(filter.should_skip("/public/images/logo.png"));
652 assert!(filter.should_skip("/assets/style.css"));
653 assert!(filter.should_skip("/api/status"));
654 assert!(filter.should_skip("/metrics"));
655 assert!(!filter.should_skip("/api/users"));
656 assert!(!filter.should_skip("/healthcheck"));
657 }
658
659 #[cfg(feature = "regex")]
660 #[test]
661 fn test_regex_path_filter() {
662 let filter = PathFilter::new()
663 .skip_regex(r"^/api/v\d+/public/.*$")
664 .expect("Valid regex");
665
666 assert!(filter.should_skip("/api/v1/public/data"));
667 assert!(filter.should_skip("/api/v2/public/files"));
668 assert!(!filter.should_skip("/api/v1/private/data"));
669 assert!(!filter.should_skip("/api/public/data"));
670 }
671
672 #[test]
673 fn test_builder_comprehensive() {
674 let auth = BasicAuthBuilder::new()
675 .user("admin", "secret")
676 .user("user", "password")
677 .users_from_iter([("guest", "guest123")])
678 .realm("Test Application")
679 .skip_paths(["/health", "/metrics"])
680 .log_failures(true)
681 .case_sensitive(false)
682 .max_header_size(4096)
683 .build()
684 .expect("Valid configuration");
685
686 assert_eq!(auth.config.realm, "Test Application");
687 assert_eq!(auth.config.max_header_size, 4096);
688 assert!(auth.config.log_failures);
689 }
690
691 #[test]
692 fn test_common_skip_paths() {
693 let filter = common_skip_paths();
694
695 assert!(filter.should_skip("/health"));
697 assert!(filter.should_skip("/healthcheck"));
698 assert!(filter.should_skip("/ping"));
699 assert!(filter.should_skip("/static/css/main.css"));
700 assert!(filter.should_skip("/favicon.ico"));
701 assert!(filter.should_skip("/public/images/logo.png"));
702 assert!(filter.should_skip("/.well-known/acme-challenge/test"));
703
704 assert!(filter.should_skip("/assets/icons.woff"));
706 assert!(filter.should_skip("/assets/icons.woff2"));
707 assert!(filter.should_skip("/assets/icons.ttf"));
708 assert!(filter.should_skip("/assets/icons.eot"));
709
710 assert!(!filter.should_skip("/status"));
713 assert!(!filter.should_skip("/metrics"));
714
715 assert!(!filter.should_skip("/api/users"));
717 assert!(!filter.should_skip("/health/live")); assert!(!filter.should_skip("/metrics.json")); let obs = observability_skip_paths();
722 assert!(obs.should_skip("/metrics"));
723 assert!(obs.should_skip("/status"));
724 }
725
726 #[test]
727 fn test_username_validation() {
728 assert!(is_valid_username("admin"));
729 assert!(is_valid_username("user123"));
730 assert!(is_valid_username("test user")); assert!(is_valid_username(""));
734 assert!(!is_valid_username("user:name")); assert!(!is_valid_username("user\nname")); assert!(!is_valid_username(&"a".repeat(256))); }
738
739 #[test]
740 fn test_builder_from_file() -> std::io::Result<()> {
741 use std::io::Write;
742
743 let mut temp_file = tempfile::NamedTempFile::new()?;
745 writeln!(temp_file, "# This is a comment")?;
746 writeln!(temp_file, "admin:secret")?;
747 writeln!(temp_file, "user:password:with:colons")?;
748 writeln!(temp_file)?; writeln!(temp_file, "guest:guest123")?;
750
751 let builder = BasicAuthBuilder::new()
752 .users_from_file(temp_file.path())
753 .expect("Failed to load users from file");
754
755 let auth = builder.build().expect("Failed to build authentication");
756
757 let validator = auth.config.validator.as_ref();
759 assert_eq!(validator.user_count(), 3);
760
761 Ok(())
762 }
763
764 #[test]
765 fn test_path_filter_modification() {
766 let filter = PathFilter::new()
767 .skip_exact("/health")
768 .skip_exact("/status");
769
770 assert_eq!(filter.pattern_count(), 2);
771
772 let filter = filter.remove_exact_path("/health");
773 assert_eq!(filter.pattern_count(), 1);
774
775 let filter = filter.clear();
776 assert_eq!(filter.pattern_count(), 0);
777 assert!(filter.is_empty());
778 }
779}