1use 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
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("regex feature not enabled. Please use: features = [\"regex\"]".to_string()))
109 }
110
111 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 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 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 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 pub fn pattern_count(&self) -> usize {
160 self.patterns.len()
161 }
162
163 pub fn is_empty(&self) -> bool {
165 self.patterns.is_empty()
166 }
167
168 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 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 pub fn clear(mut self) -> Self {
190 self.patterns.clear();
191 self
192 }
193
194 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
209pub 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 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 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 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 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 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 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; }
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 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 pub fn validator(mut self, validator: Arc<dyn UserValidator>) -> Self {
318 self.validator = Some(validator);
319 self
320 }
321
322 pub fn realm<R: Into<String>>(mut self, realm: R) -> Self {
324 self.realm = Some(realm.into());
325 self
326 }
327
328 pub fn case_sensitive(mut self, sensitive: bool) -> Self {
330 self.case_sensitive = sensitive;
331 self
332 }
333
334 #[cfg(feature = "cache")]
336 pub fn with_cache(mut self, config: CacheConfig) -> Self {
337 self.cache_config = Some(config);
338 self
339 }
340
341 #[cfg(feature = "cache")]
343 pub fn disable_cache(mut self) -> Self {
344 self.cache_config = None;
345 self
346 }
347
348 #[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 #[cfg(feature = "cache")]
358 pub fn cache_ttl_minutes(self, minutes: u64) -> Self {
359 self.cache_ttl_seconds(minutes * 60)
360 }
361
362 #[cfg(feature = "cache")]
364 pub fn cache_ttl_hours(self, hours: u64) -> Self {
365 self.cache_ttl_seconds(hours * 3600)
366 }
367
368 #[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 pub fn path_filter(mut self, filter: PathFilter) -> Self {
378 self.path_filter = Some(filter);
379 self
380 }
381
382 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 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 pub fn max_header_size(mut self, size: usize) -> Self {
406 self.max_header_size = Some(size);
407 self
408 }
409
410 pub fn log_failures(mut self, enabled: bool) -> Self {
412 self.log_failures = enabled;
413 self
414 }
415
416 pub fn max_concurrent_validations(mut self, max: usize) -> Self {
418 self.max_concurrent_validations = Some(max);
419 self
420 }
421
422 pub fn validation_timeout(mut self, timeout: Duration) -> Self {
424 self.validation_timeout = Some(timeout);
425 self
426 }
427
428 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 pub fn enable_metrics(mut self, enabled: bool) -> Self {
436 self.enable_metrics = enabled;
437 self
438 }
439
440 pub fn log_usernames_in_production(mut self, enabled: bool) -> Self {
442 self.log_usernames_in_production = enabled;
443 self
444 }
445
446 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 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 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#[macro_export]
519macro_rules! path_filter {
520 (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 (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 (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 (
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
585pub fn is_valid_username(username: &str) -> bool {
587 !username.contains(':') &&
588 !username.contains('\n') &&
589 !username.contains('\r') &&
590 username.len() <= 255 && username.chars().all(|c| c.is_ascii_graphic() || c == ' ')
592}
593
594pub(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")); assert!(is_valid_username(""));
700 assert!(!is_valid_username("user:name")); assert!(!is_valid_username("user\nname")); assert!(!is_valid_username(&"a".repeat(256))); }
704
705 #[test]
706 fn test_builder_from_file() -> std::io::Result<()> {
707 use std::io::Write;
708
709 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, "")?; 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 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}