use argon2::password_hash::{PasswordHasher, PasswordVerifier}; use argon2::{Algorithm, Argon2, Params, Version}; use crate::config::auth::PasswordPolicy; use crate::errors::{AuthError, AuthResult}; const PHC_PREFIX: &str = "$argon2"; const CLIENT_DIGEST_LEN: usize = 64; /// 遗留摘要要求长度恰为十六进制摘要长度且全为十六进制字符。 fn is_legacy_digest(value: &str) -> bool { value.len() == CLIENT_DIGEST_LEN && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } /// 离开库默认档(m=19MiB t=2 p=1):64MiB/3 轮显著抬升离线爆破成本。 fn argon2_instance() -> Argon2<'static> { const M_COST: u32 = 64 * 1024; const T_COST: u32 = 3; const P_COST: u32 = 1; match Params::new(M_COST, T_COST, P_COST, None) { Ok(params) => Argon2::new(Algorithm::default(), Version::default(), params), Err(_) => Argon2::default(), } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PasswordCheck { Match, LegacyMatch, Mismatch, } #[tracing::instrument(skip_all, fields(request_id = %envelope::context::resolve_request_id(_request_id)))] pub async fn hash(client_digest: String, _request_id: Option<&str>) -> AuthResult { blocking(move || { argon2_instance() .hash_password(client_digest.as_bytes()) .map(|hash| hash.to_string()) .map_err(|error| AuthError::PasswordHash(error.to_string())) }) .await } #[tracing::instrument(skip_all, fields(request_id = %envelope::context::resolve_request_id(_request_id)))] pub async fn verify( client_digest: String, stored: String, _request_id: Option<&str>, ) -> AuthResult { // 遗留明文摘要路径:仅用于存量账户登录时识别并触发升级重哈希; // 新账户一律 Argon2。存量清零后可整体移除该分支。 if !stored.starts_with(PHC_PREFIX) { // 空串与非法长度一律判失败:客户摘要和库里摘要都为空时「相等」会把账户变成空密码可登。 if !is_legacy_digest(&stored) || !is_legacy_digest(&client_digest) { return Ok(PasswordCheck::Mismatch); } return Ok( if digests_match(stored.as_bytes(), client_digest.as_bytes()) { PasswordCheck::LegacyMatch } else { PasswordCheck::Mismatch }, ); } blocking(move || { Ok( match argon2_instance().verify_password(client_digest.as_bytes(), stored.as_str()) { Ok(()) => PasswordCheck::Match, Err(_) => PasswordCheck::Mismatch, }, ) }) .await } pub fn digests_match(left: &[u8], right: &[u8]) -> bool { let mut difference = left.len() ^ right.len(); for index in 0..left.len().max(right.len()) { let left = left.get(index).copied().unwrap_or_default(); let right = right.get(index).copied().unwrap_or_default(); difference |= (left ^ right) as usize; } difference == 0 } pub fn validate_password(password: &str, policy: &PasswordPolicy) -> AuthResult<()> { if password.len() < policy.min_length || (policy.require_upper && !password.chars().any(|c| c.is_ascii_uppercase())) || (policy.require_lower && !password.chars().any(|c| c.is_ascii_lowercase())) || (policy.require_digit && !password.chars().any(|c| c.is_ascii_digit())) || (policy.require_symbol && !password.chars().any(|c| !c.is_ascii_alphanumeric())) { return Err(AuthError::InvalidPassword); } Ok(()) } pub fn validate_new_password(value: &str, policy: &PasswordPolicy) -> AuthResult<()> { if value.len() == CLIENT_DIGEST_LEN && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { return if value.len() >= policy.min_length { Ok(()) } else { Err(AuthError::InvalidPassword) }; } validate_password(value, policy) } async fn blocking(work: F) -> AuthResult where F: FnOnce() -> AuthResult + Send + 'static, T: Send + 'static, { match tokio::task::spawn_blocking(work).await { Ok(result) => result, Err(error) => Err(AuthError::PasswordHash(error.to_string())), } }