use crate::config::AuthPasswordConfig;
use argon2::password_hash::{PasswordHash, SaltString};
use argon2::{Argon2, Params, PasswordHasher, PasswordVerifier, Version};
use platform_core::error::ErrorDetail;
use platform_core::{AppError, AppResult, ErrorCode};
const MAX_IDENTIFIER_BYTES: usize = 512;
const MIN_PASSWORD_BYTES: usize = 8;
const MAX_PASSWORD_BYTES: usize = 1024;
pub fn normalize_identifier(identifier: &str) -> AppResult<String> {
let trimmed = identifier.trim();
if trimmed.is_empty() || trimmed.len() > MAX_IDENTIFIER_BYTES {
return Err(validation_error("identifier", "identifier is invalid"));
}
if trimmed.contains('@') {
Ok(trimmed.to_ascii_lowercase())
} else {
Ok(trimmed.to_owned())
}
}
pub fn validate_password(password: &str) -> AppResult<()> {
if password.len() < MIN_PASSWORD_BYTES || password.len() > MAX_PASSWORD_BYTES {
return Err(validation_error("password", "password is invalid"));
}
Ok(())
}
pub fn hash_password(password: &str, config: &AuthPasswordConfig) -> AppResult<String> {
let mut salt_bytes = [0u8; 16];
getrandom::fill(&mut salt_bytes).expect("OS randomness should be available");
let salt = SaltString::encode_b64(&salt_bytes)
.expect("16 random bytes should produce a valid Argon2 salt");
argon2_from_config(config)?
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| AppError::new(ErrorCode::Internal, "Password hashing failed"))
}
pub fn verify_password(password_hash: &str, password: &str) -> AppResult<bool> {
let parsed = PasswordHash::new(password_hash).map_err(|source| {
AppError::new(
ErrorCode::Internal,
format!("Stored password hash is invalid: {source}"),
)
})?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
pub fn new_session_token() -> String {
auth::public::new_session_token()
}
fn argon2_from_config(config: &AuthPasswordConfig) -> AppResult<Argon2<'static>> {
let params = Params::new(
config.argon2_memory_kib,
config.argon2_time_cost,
config.argon2_parallelism,
None,
)
.map_err(|_| AppError::new(ErrorCode::Internal, "Invalid password hash configuration"))?;
Ok(Argon2::new(
config.argon2_algorithm(),
Version::default(),
params,
))
}
fn validation_error(field: &str, reason: &str) -> AppError {
AppError::validation(
"Request validation failed",
vec![ErrorDetail {
field: Some(field.to_owned()),
reason: reason.to_owned(),
}],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_identifiers_are_trimmed_and_lowercased() {
assert_eq!(
normalize_identifier(" Ada@Example.COM ").expect("identifier should normalize"),
"ada@example.com"
);
}
#[test]
fn phone_like_identifiers_are_trimmed_without_lowercasing() {
assert_eq!(
normalize_identifier(" +8613800000000 ").expect("identifier should normalize"),
"+8613800000000"
);
}
#[test]
fn password_hash_verifies_original_password_only() {
let hash = hash_password("correct horse", &AuthPasswordConfig::default())
.expect("password should hash");
assert!(verify_password(&hash, "correct horse").expect("hash should verify"));
assert!(!verify_password(&hash, "wrong horse").expect("hash should verify"));
}
#[test]
fn password_hash_uses_configured_argon2_policy() {
let config = AuthPasswordConfig {
hash_algorithm: crate::config::HashAlgorithm::Argon2i,
argon2_memory_kib: 16 * 1024,
argon2_time_cost: 3,
argon2_parallelism: 2,
..AuthPasswordConfig::default()
};
let hash = hash_password("correct horse", &config).expect("password should hash");
let parsed = PasswordHash::new(&hash).expect("hash should parse");
assert_eq!(parsed.algorithm.to_string(), "argon2i");
assert_eq!(
parsed
.params
.get("m")
.and_then(|value| value.decimal().ok()),
Some(16 * 1024)
);
assert_eq!(
parsed
.params
.get("t")
.and_then(|value| value.decimal().ok()),
Some(3)
);
assert_eq!(
parsed
.params
.get("p")
.and_then(|value| value.decimal().ok()),
Some(2)
);
}
#[test]
fn session_tokens_are_random_enough_for_auth() {
let first = new_session_token();
let second = new_session_token();
assert!(first.starts_with("sess_"));
assert_eq!(first.len(), "sess_".len() + 64);
assert_ne!(first, second);
}
}