use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString};
use argon2::Argon2;
use async_trait::async_trait;
use rand::rngs::OsRng;
use crate::error::{IdentityError, Result};
#[async_trait]
pub trait PasswordHasher: Send + Sync + 'static {
async fn hash(&self, password: String) -> Result<String>;
async fn verify(&self, password: String, phc: String) -> Result<bool>;
fn needs_rehash(&self, phc: &str) -> bool;
}
pub struct Argon2idHasher {
argon2: Argon2<'static>,
}
impl Default for Argon2idHasher {
fn default() -> Self {
Self::new()
}
}
impl Argon2idHasher {
pub fn new() -> Self {
Self {
argon2: Argon2::default(),
}
}
fn is_bcrypt(phc: &str) -> bool {
phc.starts_with("$2a$") || phc.starts_with("$2b$") || phc.starts_with("$2y$")
}
}
#[async_trait]
impl PasswordHasher for Argon2idHasher {
async fn hash(&self, password: String) -> Result<String> {
let argon2 = self.argon2.clone();
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| IdentityError::Internal(format!("argon2 hash: {e}")))
})
.await
.map_err(|e| IdentityError::Internal(format!("join: {e}")))?
}
async fn verify(&self, password: String, phc: String) -> Result<bool> {
let argon2 = self.argon2.clone();
tokio::task::spawn_blocking(move || {
if Self::is_bcrypt(&phc) {
return Ok(bcrypt::verify(&password, &phc).unwrap_or(false));
}
match PasswordHash::new(&phc) {
Ok(parsed) => Ok(argon2.verify_password(password.as_bytes(), &parsed).is_ok()),
Err(_) => Ok(false),
}
})
.await
.map_err(|e| IdentityError::Internal(format!("join: {e}")))?
}
fn needs_rehash(&self, phc: &str) -> bool {
Self::is_bcrypt(phc)
}
}
#[derive(Debug, Clone)]
pub struct PasswordPolicy {
pub min_len: usize,
pub max_len: usize,
pub require_upper: bool,
pub require_lower: bool,
pub require_digit: bool,
pub require_symbol: bool,
}
impl Default for PasswordPolicy {
fn default() -> Self {
Self {
min_len: 12,
max_len: 4096,
require_upper: false,
require_lower: false,
require_digit: false,
require_symbol: false,
}
}
}
impl PasswordPolicy {
pub fn validate(&self, password: &str) -> Result<()> {
let len = password.chars().count();
if len < self.min_len {
return Err(reject(format!(
"must be at least {} characters",
self.min_len
)));
}
if len > self.max_len {
return Err(reject(format!(
"must be at most {} characters",
self.max_len
)));
}
if self.require_upper && !password.chars().any(|c| c.is_ascii_uppercase()) {
return Err(reject("must contain an uppercase letter"));
}
if self.require_lower && !password.chars().any(|c| c.is_ascii_lowercase()) {
return Err(reject("must contain a lowercase letter"));
}
if self.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
return Err(reject("must contain a digit"));
}
if self.require_symbol && password.chars().all(|c| c.is_alphanumeric()) {
return Err(reject("must contain a symbol"));
}
Ok(())
}
}
fn reject(msg: impl Into<String>) -> IdentityError {
IdentityError::PasswordRejected(msg.into())
}
#[async_trait::async_trait]
pub trait BreachChecker: Send + Sync + 'static {
async fn is_breached(&self, password: &str) -> Result<bool>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_enforces_length_and_composition() {
let p = PasswordPolicy::default();
assert!(p.validate("short").is_err());
assert!(p.validate("a-sufficiently-long-passphrase").is_ok());
let strict = PasswordPolicy {
min_len: 8,
require_upper: true,
require_digit: true,
require_symbol: true,
..PasswordPolicy::default()
};
assert!(strict.validate("alllowercase").is_err());
assert!(strict.validate("Str0ng!pass").is_ok());
}
#[tokio::test]
async fn argon2_roundtrip() {
let h = Argon2idHasher::new();
let phc = h.hash("s3cr3t".into()).await.unwrap();
assert!(h.verify("s3cr3t".into(), phc.clone()).await.unwrap());
assert!(!h.verify("wrong".into(), phc.clone()).await.unwrap());
assert!(!h.needs_rehash(&phc));
}
#[tokio::test]
async fn bcrypt_verifies_and_flags_rehash() {
let h = Argon2idHasher::new();
let bcrypt_hash = bcrypt::hash("legacy", 4).unwrap();
assert!(h
.verify("legacy".into(), bcrypt_hash.clone())
.await
.unwrap());
assert!(h.needs_rehash(&bcrypt_hash));
}
}