use super::*;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
impl AuthenticationManager {
pub fn new(jwt_secret: &[u8]) -> Self {
Self {
password_hasher: PasswordHasher::new(),
jwt_manager: JwtManager::new(jwt_secret),
users: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn with_config(jwt_secret: &[u8], access_expiration_secs: u64, refresh_expiration_secs: u64) -> Self {
Self {
password_hasher: PasswordHasher::new(),
jwt_manager: JwtManager::with_expiration(jwt_secret, access_expiration_secs, refresh_expiration_secs),
users: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn add_user(&self, user: User) -> AuthResult<()> {
validate_bcrypt_hash(&user.password_hash)?;
let mut users = self.users.write().await;
users.insert(user.username.clone(), user);
Ok(())
}
#[doc(hidden)]
pub(crate) async fn add_user_unchecked(&self, user: User) -> AuthResult<()> {
let mut users = self.users.write().await;
users.insert(user.username.clone(), user);
Ok(())
}
pub async fn register_user(&self, username: &str, password: &str, role: &str) -> AuthResult<()> {
self.password_hasher.validate_strength(password)?;
let password_hash = self.password_hasher.hash(password)?;
let user = User {
id: username.to_string(),
username: username.to_string(),
password_hash,
role: role.to_string(),
email: None,
created_at: None,
};
self.add_user_unchecked(user).await
}
pub async fn authenticate(&self, credentials: AuthCredentials) -> AuthResult<String> {
let users = self.users.read().await;
let user = users.get(&credentials.username).ok_or(AuthError::InvalidCredentials)?;
self.password_hasher
.verify(&credentials.password, &user.password_hash)?;
let token = self
.jwt_manager
.generate_token(&user.id, &user.username, &user.role, TokenType::Access)?;
Ok(token)
}
pub fn verify_token(&self, token: &str) -> AuthResult<JwtClaims> {
self.jwt_manager.verify_token(token)
}
pub fn refresh_token(&self, refresh_token: &str) -> AuthResult<String> {
self.jwt_manager.refresh_access_token(refresh_token)
}
pub async fn get_user(&self, username: &str) -> AuthResult<User> {
let users = self.users.read().await;
users
.get(username)
.cloned()
.ok_or_else(|| AuthError::UserNotFound(username.to_string()))
}
pub async fn remove_user(&self, username: &str) -> AuthResult<()> {
let mut users = self.users.write().await;
users
.remove(username)
.map(|_| ())
.ok_or_else(|| AuthError::UserNotFound(username.to_string()))
}
}
fn validate_bcrypt_hash(hash: &str) -> AuthResult<()> {
if hash.is_empty() {
return Err(AuthError::PasswordHash("password_hash must not be empty".to_string()));
}
if hash.len() != 60 {
return Err(AuthError::PasswordHash(format!(
"password_hash must be a valid bcrypt hash (60 chars, got {})",
hash.len()
)));
}
let prefix = &hash[..4];
if !matches!(prefix, "$2a$" | "$2b$" | "$2y$") {
return Err(AuthError::PasswordHash(
"password_hash must start with $2a$, $2b$, or $2y$".to_string(),
));
}
let cost_str = &hash[4..6];
if !cost_str.chars().all(|c| c.is_ascii_digit()) {
return Err(AuthError::PasswordHash(
"password_hash cost factor must be numeric".to_string(),
));
}
if !hash[6..7].starts_with('$') {
return Err(AuthError::PasswordHash(
"password_hash format invalid: missing $ after cost".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_bcrypt_hash_valid() {
let hasher = PasswordHasher::new();
let hash = hasher.hash("TestPass@123").unwrap();
assert!(validate_bcrypt_hash(&hash).is_ok());
}
#[test]
fn test_validate_bcrypt_hash_empty() {
assert!(validate_bcrypt_hash("").is_err());
}
#[test]
fn test_validate_bcrypt_hash_plaintext() {
assert!(validate_bcrypt_hash("plaintext").is_err());
assert!(validate_bcrypt_hash("password123").is_err());
}
#[test]
fn test_validate_bcrypt_hash_wrong_prefix() {
let fake_hash = "$3a$12$01234567890123456789012345678901234567890123456789";
assert!(validate_bcrypt_hash(fake_hash).is_err());
}
#[test]
fn test_validate_bcrypt_hash_wrong_length() {
let short_hash = "$2b$12$short";
assert!(validate_bcrypt_hash(short_hash).is_err());
}
#[test]
fn test_validate_bcrypt_hash_non_numeric_cost() {
let fake_hash = "$2b$ab$0123456789012345678901234567890123456789012345678";
assert!(validate_bcrypt_hash(fake_hash).is_err());
}
#[tokio::test]
async fn test_vuln_0002_add_user_rejects_empty_hash() {
let mgr = AuthenticationManager::new(b"secret");
let user = User {
id: "u1".to_string(),
username: "test".to_string(),
password_hash: "".to_string(),
role: "user".to_string(),
email: None,
created_at: None,
};
let result = mgr.add_user(user).await;
assert!(
matches!(result, Err(AuthError::PasswordHash(_))),
"add_user must reject empty password_hash"
);
}
#[tokio::test]
async fn test_vuln_0002_add_user_rejects_plaintext_hash() {
let mgr = AuthenticationManager::new(b"secret");
let user = User {
id: "u1".to_string(),
username: "test".to_string(),
password_hash: "plaintext_password".to_string(),
role: "user".to_string(),
email: None,
created_at: None,
};
let result = mgr.add_user(user).await;
assert!(
matches!(result, Err(AuthError::PasswordHash(_))),
"add_user must reject non-bcrypt password_hash"
);
}
#[tokio::test]
async fn test_vuln_0002_add_user_accepts_valid_bcrypt() {
let mgr = AuthenticationManager::new(b"secret");
let hasher = PasswordHasher::new();
let hash = hasher.hash("ValidPass@123").unwrap();
let user = User {
id: "u1".to_string(),
username: "test".to_string(),
password_hash: hash,
role: "user".to_string(),
email: None,
created_at: None,
};
let result = mgr.add_user(user).await;
assert!(result.is_ok(), "add_user should accept valid bcrypt hash");
}
#[tokio::test]
async fn test_vuln_0002_add_user_unchecked_skips_validation() {
let mgr = AuthenticationManager::new(b"secret");
let user = User {
id: "u1".to_string(),
username: "test".to_string(),
password_hash: "anything".to_string(),
role: "user".to_string(),
email: None,
created_at: None,
};
let result = mgr.add_user_unchecked(user).await;
assert!(
result.is_ok(),
"add_user_unchecked should skip validation for internal use"
);
}
#[tokio::test]
async fn test_hd3_add_user_vs_unchecked_role_separation() {
let mgr = AuthenticationManager::new(b"secret");
let make_user = || User {
id: "u1".to_string(),
username: "test_hd3".to_string(),
password_hash: "invalid-not-bcrypt".to_string(),
role: "user".to_string(),
email: None,
created_at: None,
};
let result = mgr.add_user(make_user()).await;
assert!(
matches!(result, Err(AuthError::PasswordHash(_))),
"HD-3: add_user (public API) must reject invalid bcrypt hash"
);
let result = mgr.add_user_unchecked(make_user()).await;
assert!(
result.is_ok(),
"HD-3: add_user_unchecked (pub(crate) internal) should skip validation"
);
}
#[tokio::test]
async fn test_hd3_add_user_unchecked_data_round_trip() {
let mgr = AuthenticationManager::new(b"secret");
let user = User {
id: "migrated-1".to_string(),
username: "migrated_user".to_string(),
password_hash: "$2b$12$somefakebutnonemptyhashplaceholder012345678901234567890123456".to_string(),
role: "user".to_string(),
email: Some("migrated@example.com".to_string()),
created_at: None,
};
mgr.add_user_unchecked(user)
.await
.expect("internal write should succeed");
let read = mgr
.get_user("migrated_user")
.await
.expect("get_user should find migrated user");
assert_eq!(read.id, "migrated-1");
assert_eq!(read.role, "user");
assert_eq!(read.email.as_deref(), Some("migrated@example.com"));
}
}