use crate::{
error::{AuthError, AuthResult},
is_valid_username,
limiter::{ConcurrencyLimiter, RateLimiter},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use ntex::time::timeout;
use ntex::{Middleware, Service, ServiceCtx, web};
use std::collections::HashMap;
use std::fmt::Debug;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
#[cfg(feature = "timing-safe")]
use subtle::ConstantTimeEq;
#[cfg(feature = "secure-memory")]
use zeroize::{Zeroize, ZeroizeOnDrop};
#[cfg(feature = "cache")]
use {
crate::cache::{AuthCache, CacheConfig},
sha2::{Digest, Sha256},
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "secure-memory", derive(Zeroize, ZeroizeOnDrop))]
pub struct Credentials {
#[cfg_attr(feature = "secure-memory", zeroize(skip))]
pub username: String,
pub password: String,
}
impl Credentials {
pub fn new(username: String, password: String) -> Self {
Self { username, password }
}
#[cfg(feature = "cache")]
pub fn cache_key(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"ntex-basicauth-v1:");
hasher.update(self.username.as_bytes());
hasher.update(b":");
hasher.update(self.password.as_bytes());
hasher.finalize().into()
}
#[cfg(feature = "timing-safe")]
pub fn verify_password(&self, expected: &str) -> bool {
use sha2::{Digest, Sha256};
let mut ha = Sha256::new();
ha.update(self.password.as_bytes());
let mut hb = Sha256::new();
hb.update(expected.as_bytes());
let a = ha.finalize();
let b = hb.finalize();
a.as_slice().ct_eq(b.as_slice()).into()
}
#[cfg(not(feature = "timing-safe"))]
pub fn verify_password(&self, expected: &str) -> bool {
self.password == expected
}
pub fn username_ref(&self) -> &str {
&self.username
}
pub fn is_valid_format(&self) -> bool {
is_valid_username(&self.username) && !self.password.chars().any(|c| c.is_control())
}
}
pub trait UserValidator: Send + Sync + Debug {
fn validate<'a>(
&'a self,
credentials: &'a Credentials,
) -> Pin<Box<dyn Future<Output = AuthResult<bool>> + Send + 'a>>;
fn name(&self) -> &'static str {
"UserValidator"
}
fn pre_validate(&self, credentials: &Credentials) -> AuthResult<()> {
if !credentials.is_valid_format() {
return Err(AuthError::InvalidCredentials);
}
Ok(())
}
fn user_count(&self) -> usize {
0
}
}
#[derive(Debug)]
pub struct StaticUserValidator {
users: HashMap<String, String>,
case_sensitive: bool,
}
impl StaticUserValidator {
pub fn new() -> Self {
Self {
users: HashMap::new(),
case_sensitive: true,
}
}
pub fn case_insensitive(mut self) -> Self {
self.case_sensitive = false;
self
}
pub fn add_user(&mut self, username: String, password: String) -> &mut Self {
let key = if self.case_sensitive {
username
} else {
username.to_lowercase()
};
self.users.insert(key, password);
self
}
pub fn from_map(users: HashMap<String, String>) -> Self {
Self {
users,
case_sensitive: true,
}
}
pub fn from_map_case_insensitive(users: HashMap<String, String>) -> Self {
let normalized_users: HashMap<String, String> = users
.into_iter()
.map(|(k, v)| (k.to_lowercase(), v))
.collect();
Self {
users: normalized_users,
case_sensitive: false,
}
}
pub fn contains_user(&self, username: &str) -> bool {
let key = if self.case_sensitive {
username
} else {
&username.to_lowercase()
};
self.users.contains_key(key)
}
}
impl Default for StaticUserValidator {
fn default() -> Self {
Self::new()
}
}
impl UserValidator for StaticUserValidator {
fn validate<'a>(
&'a self,
credentials: &'a Credentials,
) -> Pin<Box<dyn Future<Output = AuthResult<bool>> + Send + 'a>> {
Box::pin(async move {
let username = if self.case_sensitive {
&credentials.username
} else {
&credentials.username.to_lowercase()
};
match self.users.get(username) {
Some(stored_password) => Ok(credentials.verify_password(stored_password)),
None => Ok(false),
}
})
}
fn name(&self) -> &'static str {
"StaticUserValidator"
}
fn user_count(&self) -> usize {
self.users.len()
}
}
#[cfg(feature = "bcrypt")]
#[derive(Debug)]
pub struct BcryptUserValidator {
users: HashMap<String, String>, cost: u32,
}
#[cfg(feature = "bcrypt")]
impl BcryptUserValidator {
pub fn new() -> Self {
Self {
users: HashMap::new(),
cost: bcrypt::DEFAULT_COST,
}
}
pub fn with_cost(mut self, cost: u32) -> Self {
self.cost = cost;
self
}
pub fn add_user(&mut self, username: String, bcrypt_hash: String) -> &mut Self {
self.users.insert(username, bcrypt_hash);
self
}
pub fn add_user_with_password(
&mut self,
username: String,
password: &str,
) -> AuthResult<&mut Self> {
let hash = bcrypt::hash(password, self.cost)
.map_err(|e| AuthError::ValidationFailed(format!("BCrypt hash failed: {}", e)))?;
self.users.insert(username, hash);
Ok(self)
}
pub fn from_hashes(users: HashMap<String, String>) -> Self {
Self {
users,
cost: bcrypt::DEFAULT_COST,
}
}
}
#[cfg(feature = "bcrypt")]
impl Default for BcryptUserValidator {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "bcrypt")]
impl UserValidator for BcryptUserValidator {
fn validate<'a>(
&'a self,
credentials: &'a Credentials,
) -> Pin<Box<dyn Future<Output = AuthResult<bool>> + Send + 'a>> {
Box::pin(async move {
match self.users.get(&credentials.username) {
Some(stored_hash) => {
let password = credentials.password.clone();
let hash = stored_hash.clone();
let result =
ntex::rt::spawn_blocking(move || bcrypt::verify(&password, &hash)).await;
match result {
Ok(Ok(is_valid)) => Ok(is_valid),
Ok(Err(e)) => Err(AuthError::ValidationFailed(format!(
"BCrypt verify failed: {}",
e
))),
Err(e) => Err(AuthError::InternalError(format!("Task join failed: {}", e))),
}
}
None => Ok(false),
}
})
}
fn name(&self) -> &'static str {
"BcryptUserValidator"
}
fn user_count(&self) -> usize {
self.users.len()
}
}
#[derive(Debug, Default)]
pub struct AuthMetrics {
pub total_requests: AtomicU64,
pub successful_auths: AtomicU64,
pub failed_auths: AtomicU64,
pub cached_hits: AtomicU64,
pub validation_time_ms: AtomicU64,
}
impl AuthMetrics {
pub fn new() -> Self {
Self::default()
}
pub fn total_requests(&self) -> u64 {
self.total_requests.load(Ordering::Relaxed)
}
pub fn successful_auths(&self) -> u64 {
self.successful_auths.load(Ordering::Relaxed)
}
pub fn failed_auths(&self) -> u64 {
self.failed_auths.load(Ordering::Relaxed)
}
pub fn cached_hits(&self) -> u64 {
self.cached_hits.load(Ordering::Relaxed)
}
pub fn avg_validation_time_ms(&self) -> f64 {
let total_time = self.validation_time_ms.load(Ordering::Relaxed);
let total_requests = self.total_requests.load(Ordering::Relaxed);
if total_requests > 0 {
total_time as f64 / total_requests as f64
} else {
0.0
}
}
pub fn success_rate(&self) -> f64 {
let successful = self.successful_auths.load(Ordering::Relaxed);
let total = self.total_requests.load(Ordering::Relaxed);
if total > 0 {
(successful as f64 / total as f64) * 100.0
} else {
0.0
}
}
pub fn cache_hit_rate(&self) -> f64 {
let hits = self.cached_hits.load(Ordering::Relaxed);
let total = self.total_requests.load(Ordering::Relaxed);
if total > 0 {
(hits as f64 / total as f64) * 100.0
} else {
0.0
}
}
pub fn reset(&self) {
self.total_requests.store(0, Ordering::Relaxed);
self.successful_auths.store(0, Ordering::Relaxed);
self.failed_auths.store(0, Ordering::Relaxed);
self.cached_hits.store(0, Ordering::Relaxed);
self.validation_time_ms.store(0, Ordering::Relaxed);
}
pub fn incr_total_requests(&self) {
self.total_requests.fetch_add(1, Ordering::Relaxed);
}
pub fn incr_successful_auths(&self) {
self.successful_auths.fetch_add(1, Ordering::Relaxed);
}
pub fn incr_failed_auths(&self) {
self.failed_auths.fetch_add(1, Ordering::Relaxed);
}
pub fn incr_cached_hits(&self) {
self.cached_hits.fetch_add(1, Ordering::Relaxed);
}
pub fn add_validation_time(&self, duration: Duration) {
let ms = duration.as_millis() as u64;
self.validation_time_ms.fetch_add(ms, Ordering::Relaxed);
}
}
pub type CustomErrorHandler = Arc<dyn Fn(&AuthError, &str) -> web::HttpResponse + Send + Sync>;
pub struct BasicAuthConfig {
pub realm: String,
pub validator: Arc<dyn UserValidator>,
#[cfg(feature = "cache")]
pub cache: Option<Arc<AuthCache>>,
pub path_filter: Option<Arc<crate::utils::PathFilter>>,
pub max_header_size: usize,
pub log_failures: bool,
pub custom_error_handler: Option<CustomErrorHandler>,
pub max_concurrent_validations: Option<usize>,
pub validation_timeout: Option<Duration>,
pub rate_limit_per_ip: Option<(usize, Duration)>,
pub client_ip_header: Option<String>,
pub enable_metrics: bool,
pub log_usernames_in_production: bool,
}
impl BasicAuthConfig {
pub fn new(validator: Arc<dyn UserValidator>) -> Self {
Self {
realm: "Restricted Area".to_string(),
validator,
#[cfg(feature = "cache")]
cache: None,
path_filter: None,
max_header_size: 8192, log_failures: false,
custom_error_handler: None,
max_concurrent_validations: None,
validation_timeout: Some(Duration::from_secs(30)),
rate_limit_per_ip: None,
client_ip_header: None,
enable_metrics: true,
log_usernames_in_production: false,
}
}
pub fn realm(mut self, realm: String) -> Self {
self.realm = realm;
self
}
#[cfg(feature = "cache")]
pub fn with_cache(mut self, cache_config: CacheConfig) -> AuthResult<Self> {
self.cache = Some(Arc::new(AuthCache::new(cache_config)?));
Ok(self)
}
#[cfg(feature = "cache")]
pub fn disable_cache(mut self) -> Self {
self.cache = None;
self
}
pub fn path_filter(mut self, filter: crate::utils::PathFilter) -> Self {
self.path_filter = Some(Arc::new(filter));
self
}
pub fn max_header_size(mut self, size: usize) -> Self {
self.max_header_size = size;
self
}
pub fn log_failures(mut self, enabled: bool) -> Self {
self.log_failures = enabled;
self
}
pub fn custom_error_handler<F>(mut self, handler: F) -> Self
where
F: Fn(&AuthError, &str) -> web::HttpResponse + Send + Sync + 'static,
{
self.custom_error_handler = Some(Arc::new(handler));
self
}
pub fn max_concurrent_validations(mut self, max: usize) -> Self {
self.max_concurrent_validations = Some(max);
self
}
pub fn validation_timeout(mut self, timeout: Duration) -> Self {
self.validation_timeout = Some(timeout);
self
}
pub fn rate_limit_per_ip(mut self, max_requests: usize, window: Duration) -> Self {
self.rate_limit_per_ip = Some((max_requests, window));
self
}
pub fn client_ip_header(mut self, header: impl Into<String>) -> Self {
self.client_ip_header = Some(header.into());
self
}
pub fn enable_metrics(mut self, enabled: bool) -> Self {
self.enable_metrics = enabled;
self
}
pub fn log_usernames_in_production(mut self, enabled: bool) -> Self {
self.log_usernames_in_production = enabled;
self
}
pub fn validate(&self) -> AuthResult<()> {
if self.realm.is_empty() {
return Err(AuthError::ConfigError("realm cannot be empty".to_string()));
}
if self.max_header_size == 0 {
return Err(AuthError::ConfigError(
"max_header_size must be greater than 0".to_string(),
));
}
if self.max_header_size > 1024 * 1024 {
return Err(AuthError::ConfigError(
"max_header_size too large (max 1MB)".to_string(),
));
}
if let Some(max_concurrent) = self.max_concurrent_validations {
if max_concurrent == 0 {
return Err(AuthError::ConfigError(
"max_concurrent_validations must be greater than 0".to_string(),
));
}
if max_concurrent > 10000 {
return Err(AuthError::ConfigError(
"max_concurrent_validations too large (max 10000)".to_string(),
));
}
}
if let Some(timeout) = self.validation_timeout {
if timeout.is_zero() {
return Err(AuthError::ConfigError(
"validation_timeout must be greater than 0".to_string(),
));
}
if timeout > Duration::from_secs(300) {
return Err(AuthError::ConfigError(
"validation_timeout too large (max 5 minutes)".to_string(),
));
}
}
if let Some((max_requests, window)) = self.rate_limit_per_ip {
if max_requests == 0 {
return Err(AuthError::ConfigError(
"rate_limit max_requests must be greater than 0".to_string(),
));
}
if window.is_zero() {
return Err(AuthError::ConfigError(
"rate_limit window must be greater than 0".to_string(),
));
}
}
#[cfg(feature = "cache")]
if let Some(cache) = &self.cache {
let stats = cache.stats();
if stats.total_entries > 100000 {
eprintln!(
"Warning: Cache has {} entries, consider reducing TTL",
stats.total_entries
);
}
}
Ok(())
}
}
pub struct BasicAuth {
pub(crate) config: BasicAuthConfig,
pub(crate) metrics: Arc<AuthMetrics>,
pub(crate) concurrency_limiter: Option<Arc<ConcurrencyLimiter>>,
pub(crate) rate_limiter: Option<Arc<RateLimiter>>,
}
impl BasicAuth {
pub fn new(config: BasicAuthConfig) -> AuthResult<Self> {
config.validate()?;
let concurrency_limiter = config
.max_concurrent_validations
.map(|max| Arc::new(ConcurrencyLimiter::new(max)));
let rate_limiter = config
.rate_limit_per_ip
.map(|(max_requests, window)| Arc::new(RateLimiter::new(max_requests, window)));
Ok(Self {
config,
metrics: Arc::new(AuthMetrics::new()),
concurrency_limiter,
rate_limiter,
})
}
pub fn metrics(&self) -> &AuthMetrics {
&self.metrics
}
pub fn with_users(users: HashMap<String, String>) -> AuthResult<Self> {
let validator = Arc::new(StaticUserValidator::from_map(users));
let config = BasicAuthConfig::new(validator);
Self::new(config)
}
pub fn with_user(username: String, password: String) -> AuthResult<Self> {
let mut users = HashMap::new();
users.insert(username, password);
Self::with_users(users)
}
fn parse_credentials(auth_header: &str, max_size: usize) -> AuthResult<Credentials> {
if auth_header.len() > max_size {
return Err(AuthError::InvalidFormat);
}
let scheme = auth_header.get(..6).ok_or(AuthError::InvalidFormat)?;
if !scheme.eq_ignore_ascii_case("Basic ") {
return Err(AuthError::InvalidFormat);
}
let encoded = &auth_header[6..];
if encoded.len() > (max_size * 3 / 4) {
return Err(AuthError::InvalidFormat);
}
let decoded = STANDARD
.decode(encoded)
.map_err(|_| AuthError::InvalidBase64)?;
let decoded_str = std::str::from_utf8(&decoded).map_err(|_| AuthError::InvalidBase64)?;
let (username, password) = decoded_str
.split_once(':')
.ok_or(AuthError::InvalidFormat)?;
let credentials = Credentials::new(username.to_string(), password.to_string());
if !credentials.is_valid_format() {
return Err(AuthError::InvalidCredentials);
}
Ok(credentials)
}
async fn authenticate(&self, credentials: &Credentials) -> AuthResult<bool> {
self.config.validator.pre_validate(credentials)?;
#[cfg(feature = "cache")]
{
if let Some(cache) = &self.config.cache {
let cache_key = credentials.cache_key();
if let Some(cached_result) = cache.get(&cache_key) {
if self.config.enable_metrics {
self.metrics.incr_cached_hits();
}
return Ok(cached_result);
}
let start = Instant::now();
let result = self.run_validation(credentials).await?;
if self.config.enable_metrics {
self.metrics.add_validation_time(start.elapsed());
}
if let Err(e) = cache.insert(cache_key, result) {
eprintln!("Failed to cache authentication result: {}", e);
}
return Ok(result);
}
}
let start = Instant::now();
let result = self.run_validation(credentials).await?;
if self.config.enable_metrics {
self.metrics.add_validation_time(start.elapsed());
}
Ok(result)
}
async fn run_validation(&self, credentials: &Credentials) -> AuthResult<bool> {
let Some(limiter) = &self.concurrency_limiter else {
let validate = self.config.validator.validate(credentials);
return match self.config.validation_timeout {
Some(timeout_dur) => timeout(timeout_dur, validate)
.await
.map_err(|_| AuthError::InternalError("Validation timed out".to_string()))?,
None => validate.await,
};
};
let validator = Arc::clone(&self.config.validator);
let limiter = Arc::clone(limiter);
let credentials = credentials.clone();
let task = ntex::rt::spawn(async move {
let _permit = limiter.acquire().await;
validator.validate(&credentials).await
});
match self.config.validation_timeout {
Some(timeout_dur) => match timeout(timeout_dur, task).await {
Ok(join_result) => join_result
.map_err(|_| AuthError::InternalError("Validation task failed".to_string()))?,
Err(_) => Err(AuthError::InternalError("Validation timed out".to_string())),
},
None => task
.await
.map_err(|_| AuthError::InternalError("Validation task failed".to_string()))?,
}
}
fn client_ip<Err>(&self, req: &web::WebRequest<Err>) -> String
where
Err: web::ErrorRenderer,
{
if let Some(header) = &self.config.client_ip_header
&& let Some(value) = req.headers().get(header).and_then(|v| v.to_str().ok())
&& let Some(first) = value.split(',').next()
{
let ip = first.trim();
if !ip.is_empty() {
return ip.to_string();
}
}
req.peer_addr()
.map(|addr| addr.ip().to_string())
.unwrap_or_default()
}
fn handle_auth_error(&self, error: &AuthError) -> web::HttpResponse {
if let Some(handler) = &self.config.custom_error_handler {
handler(error, &self.config.realm)
} else {
error.to_response(&self.config.realm)
}
}
fn log_auth_failure(&self, error: &AuthError, username: Option<&str>) {
if self.config.log_failures {
let safe_username = if self.config.log_usernames_in_production || cfg!(debug_assertions)
{
username
} else {
None };
match safe_username {
Some(user) => eprintln!("Authentication failed - user: {}, error: {}", user, error),
None => eprintln!("Authentication failed - error: {}", error),
}
}
}
}
impl<S, Cfg> Middleware<S, Cfg> for BasicAuth {
type Service = BasicAuthMiddlewareService<S>;
fn create(&self, service: S, _cfg: Cfg) -> Self::Service {
BasicAuthMiddlewareService {
service,
auth: BasicAuth {
config: BasicAuthConfig {
realm: self.config.realm.clone(),
validator: Arc::clone(&self.config.validator),
#[cfg(feature = "cache")]
cache: self.config.cache.clone(),
path_filter: self.config.path_filter.clone(),
max_header_size: self.config.max_header_size,
log_failures: self.config.log_failures,
custom_error_handler: self.config.custom_error_handler.clone(),
max_concurrent_validations: self.config.max_concurrent_validations,
validation_timeout: self.config.validation_timeout,
rate_limit_per_ip: self.config.rate_limit_per_ip,
client_ip_header: self.config.client_ip_header.clone(),
enable_metrics: self.config.enable_metrics,
log_usernames_in_production: self.config.log_usernames_in_production,
},
metrics: Arc::clone(&self.metrics),
concurrency_limiter: self.concurrency_limiter.clone(),
rate_limiter: self.rate_limiter.clone(),
},
}
}
}
pub struct BasicAuthMiddlewareService<S> {
service: S,
auth: BasicAuth,
}
impl<S, Err> Service<web::WebRequest<Err>> for BasicAuthMiddlewareService<S>
where
S: Service<web::WebRequest<Err>, Response = web::WebResponse, Error = web::Error> + 'static,
Err: web::ErrorRenderer,
{
type Response = web::WebResponse;
type Error = web::Error;
async fn call(
&self,
req: web::WebRequest<Err>,
ctx: ServiceCtx<'_, Self>,
) -> Result<Self::Response, Self::Error> {
let metrics_enabled = self.auth.config.enable_metrics;
if let Some(filter) = &self.auth.config.path_filter
&& filter.should_skip(req.path())
{
return ctx.call(&self.service, req).await;
}
if metrics_enabled {
self.auth.metrics.incr_total_requests();
}
if let Some(rate_limiter) = &self.auth.rate_limiter {
let ip = self.auth.client_ip(&req);
if let Err(err) = rate_limiter.check(&ip) {
self.auth.log_auth_failure(&err, None);
let response = self.auth.handle_auth_error(&err);
if metrics_enabled {
self.auth.metrics.incr_failed_auths();
}
return Ok(req.into_response(response));
}
}
let auth_header = req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok());
let auth_header = match auth_header {
Some(header) => header,
None => {
let error = AuthError::MissingHeader;
self.auth.log_auth_failure(&error, None);
let response = self.auth.handle_auth_error(&error);
if metrics_enabled {
self.auth.metrics.incr_failed_auths();
}
return Ok(req.into_response(response));
}
};
let credentials =
match BasicAuth::parse_credentials(auth_header, self.auth.config.max_header_size) {
Ok(creds) => creds,
Err(err) => {
self.auth.log_auth_failure(&err, None);
let response = self.auth.handle_auth_error(&err);
if metrics_enabled {
self.auth.metrics.incr_failed_auths();
}
return Ok(req.into_response(response));
}
};
let is_authenticated = match self.auth.authenticate(&credentials).await {
Ok(result) => result,
Err(err) => {
self.auth
.log_auth_failure(&err, Some(&credentials.username));
let response = self.auth.handle_auth_error(&err);
if metrics_enabled {
self.auth.metrics.incr_failed_auths();
}
return Ok(req.into_response(response));
}
};
if !is_authenticated {
let error = AuthError::InvalidCredentials;
self.auth
.log_auth_failure(&error, Some(&credentials.username));
let response = self.auth.handle_auth_error(&error);
if metrics_enabled {
self.auth.metrics.incr_failed_auths();
}
return Ok(req.into_response(response));
}
if metrics_enabled {
self.auth.metrics.incr_successful_auths();
}
req.extensions_mut().insert(credentials);
ctx.call(&self.service, req).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[tokio::test]
async fn test_static_validator() {
let mut users = HashMap::new();
users.insert("admin".to_string(), "secret".to_string());
users.insert("user".to_string(), "password:with:colons".to_string());
let validator = StaticUserValidator::from_map(users);
let valid_creds = Credentials::new("admin".to_string(), "secret".to_string());
let colon_password_creds =
Credentials::new("user".to_string(), "password:with:colons".to_string());
let invalid_creds = Credentials::new("admin".to_string(), "wrong".to_string());
assert!(validator.validate(&valid_creds).await.unwrap());
assert!(validator.validate(&colon_password_creds).await.unwrap());
assert!(!validator.validate(&invalid_creds).await.unwrap());
}
#[test]
fn test_parse_credentials_with_colons() {
use base64::Engine;
let credentials = "admin:pass:word:with:colons";
let encoded = STANDARD.encode(credentials.as_bytes());
let auth_header = format!("Basic {}", encoded);
let creds = BasicAuth::parse_credentials(&auth_header, 8192).unwrap();
assert_eq!(creds.username, "admin");
assert_eq!(creds.password, "pass:word:with:colons");
}
#[test]
fn test_parse_credentials_multibyte_no_panic() {
let malicious = "abc😀garbage";
let result = BasicAuth::parse_credentials(malicious, 8192);
assert!(matches!(result, Err(AuthError::InvalidFormat)));
}
#[test]
fn test_credentials_validation() {
let valid_creds = Credentials::new("user".to_string(), "pass".to_string());
let valid_empty_user = Credentials::new("".to_string(), "pass".to_string()); let invalid_creds1 = Credentials::new("user:name".to_string(), "pass".to_string());
let invalid_creds2 = Credentials::new("user".to_string(), "pass\nword".to_string());
let invalid_creds3 = Credentials::new("user".to_string(), "pass\tword".to_string());
assert!(valid_creds.is_valid_format());
assert!(valid_empty_user.is_valid_format());
assert!(!invalid_creds1.is_valid_format());
assert!(!invalid_creds2.is_valid_format());
assert!(!invalid_creds3.is_valid_format());
}
#[cfg(feature = "cache")]
#[test]
fn test_secure_cache_key() {
let creds = Credentials::new("admin".to_string(), "secret".to_string());
let key1 = creds.cache_key();
let key2 = creds.cache_key();
assert_eq!(key1, key2);
assert_eq!(key1.len(), 32);
}
#[test]
fn test_case_insensitive_validator() {
let mut users = HashMap::new();
users.insert("admin".to_string(), "secret".to_string());
let validator = StaticUserValidator::from_map_case_insensitive(users);
assert!(validator.contains_user("admin"));
assert!(validator.contains_user("ADMIN"));
assert!(validator.contains_user("Admin"));
}
#[tokio::test]
async fn test_validator_pre_validation() {
let validator = StaticUserValidator::new();
let invalid_creds = Credentials::new("user:name".to_string(), "pass".to_string());
assert!(validator.pre_validate(&invalid_creds).is_err());
}
#[test]
fn test_config_validation() {
let validator = Arc::new(StaticUserValidator::new());
let valid_config = BasicAuthConfig::new(validator.clone());
assert!(valid_config.validate().is_ok());
let invalid_config = BasicAuthConfig::new(validator).realm("".to_string());
assert!(invalid_config.validate().is_err());
}
#[cfg(feature = "bcrypt")]
#[tokio::test]
async fn test_bcrypt_validator() {
let mut validator = BcryptUserValidator::new();
validator
.add_user_with_password("admin".to_string(), "secret")
.unwrap();
let valid_creds = Credentials::new("admin".to_string(), "secret".to_string());
let invalid_creds = Credentials::new("admin".to_string(), "wrong".to_string());
assert!(validator.validate(&valid_creds).await.unwrap());
assert!(!validator.validate(&invalid_creds).await.unwrap());
}
#[ntex::test]
async fn test_rate_limit_per_ip() {
use base64::Engine;
use std::time::Duration;
let auth = crate::BasicAuthBuilder::new()
.user("admin", "secret")
.rate_limit_per_ip(1, Duration::from_secs(60))
.build()
.unwrap();
let app = ntex::web::test::init_service(
ntex::web::App::new()
.middleware(auth)
.route("/", ntex::web::get().to(|| async { "ok" })),
)
.await;
let auth_hdr = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("admin:secret")
);
let ip: std::net::SocketAddr = "1.2.3.4:80".parse().unwrap();
let req = ntex::web::test::TestRequest::get()
.peer_addr(ip)
.header("authorization", auth_hdr.as_str())
.to_request();
let resp = ntex::web::test::call_service(&app, req).await;
assert!(resp.status().is_success());
let req = ntex::web::test::TestRequest::get()
.peer_addr(ip)
.header("authorization", auth_hdr.as_str())
.to_request();
let resp = ntex::web::test::call_service(&app, req).await;
assert_eq!(resp.status(), ntex::http::StatusCode::TOO_MANY_REQUESTS);
}
#[ntex::test]
async fn test_validation_timeout() {
use base64::Engine;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug)]
struct SlowValidator;
impl UserValidator for SlowValidator {
fn validate<'a>(
&'a self,
_: &'a Credentials,
) -> Pin<Box<dyn Future<Output = AuthResult<bool>> + Send + 'a>> {
Box::pin(async {
ntex::time::sleep(Duration::from_secs(5)).await;
Ok(true)
})
}
}
let validator = Arc::new(SlowValidator);
let config = BasicAuthConfig::new(validator).validation_timeout(Duration::from_millis(100));
let auth = BasicAuth::new(config).unwrap();
let app = ntex::web::test::init_service(
ntex::web::App::new()
.middleware(auth)
.route("/", ntex::web::get().to(|| async { "ok" })),
)
.await;
let auth_hdr = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("admin:secret")
);
let req = ntex::web::test::TestRequest::get()
.header("authorization", auth_hdr.as_str())
.to_request();
let resp = ntex::web::test::call_service(&app, req).await;
assert_eq!(resp.status(), ntex::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[ntex::test]
async fn test_concurrency_limited_auth_succeeds() {
use base64::Engine;
let auth = crate::BasicAuthBuilder::new()
.user("admin", "secret")
.max_concurrent_validations(2)
.build()
.unwrap();
let app = ntex::web::test::init_service(
ntex::web::App::new()
.middleware(auth)
.route("/", ntex::web::get().to(|| async { "ok" })),
)
.await;
let auth_hdr = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("admin:secret")
);
let req = ntex::web::test::TestRequest::get()
.header("authorization", auth_hdr.as_str())
.to_request();
let resp = ntex::web::test::call_service(&app, req).await;
assert!(resp.status().is_success());
}
#[ntex::test]
async fn test_rate_limit_uses_forwarded_header() {
use base64::Engine;
use std::time::Duration;
let auth = crate::BasicAuthBuilder::new()
.user("admin", "secret")
.rate_limit_per_ip(1, Duration::from_secs(60))
.client_ip_header("x-forwarded-for")
.build()
.unwrap();
let app = ntex::web::test::init_service(
ntex::web::App::new()
.middleware(auth)
.route("/", ntex::web::get().to(|| async { "ok" })),
)
.await;
let auth_hdr = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("admin:secret")
);
for (i, expect_ok) in [(0u8, true), (1u8, false)] {
let req = ntex::web::test::TestRequest::get()
.header("authorization", auth_hdr.as_str())
.header("x-forwarded-for", "9.9.9.9, 10.0.0.1")
.to_request();
let resp = ntex::web::test::call_service(&app, req).await;
assert_eq!(
resp.status().is_success(),
expect_ok,
"request {i} unexpected status {}",
resp.status()
);
}
let req = ntex::web::test::TestRequest::get()
.header("authorization", auth_hdr.as_str())
.header("x-forwarded-for", "8.8.8.8")
.to_request();
let resp = ntex::web::test::call_service(&app, req).await;
assert!(resp.status().is_success());
}
}