main
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<String> {
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<PasswordCheck> {
// 遗留明文摘要路径:仅用于存量账户登录时识别并触发升级重哈希;
// 新账户一律 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<T, F>(work: F) -> AuthResult<T>
where
F: FnOnce() -> AuthResult<T> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(work).await {
Ok(result) => result,
Err(error) => Err(AuthError::PasswordHash(error.to_string())),
}
}