Skip to main content

auth_password/
password.rs

1use crate::config::AuthPasswordConfig;
2use argon2::password_hash::{PasswordHash, SaltString};
3use argon2::{Argon2, Params, PasswordHasher, PasswordVerifier, Version};
4use platform_core::error::ErrorDetail;
5use platform_core::{AppError, AppResult, ErrorCode};
6
7const MAX_IDENTIFIER_BYTES: usize = 512;
8const MIN_PASSWORD_BYTES: usize = 8;
9const MAX_PASSWORD_BYTES: usize = 1024;
10
11pub fn normalize_identifier(identifier: &str) -> AppResult<String> {
12    let trimmed = identifier.trim();
13    if trimmed.is_empty() || trimmed.len() > MAX_IDENTIFIER_BYTES {
14        return Err(validation_error("identifier", "identifier is invalid"));
15    }
16
17    if trimmed.contains('@') {
18        Ok(trimmed.to_ascii_lowercase())
19    } else {
20        Ok(trimmed.to_owned())
21    }
22}
23
24pub fn validate_password(password: &str) -> AppResult<()> {
25    if password.len() < MIN_PASSWORD_BYTES || password.len() > MAX_PASSWORD_BYTES {
26        return Err(validation_error("password", "password is invalid"));
27    }
28    Ok(())
29}
30
31pub fn hash_password(password: &str, config: &AuthPasswordConfig) -> AppResult<String> {
32    let mut salt_bytes = [0u8; 16];
33    getrandom::fill(&mut salt_bytes).expect("OS randomness should be available");
34    let salt = SaltString::encode_b64(&salt_bytes)
35        .expect("16 random bytes should produce a valid Argon2 salt");
36    argon2_from_config(config)?
37        .hash_password(password.as_bytes(), &salt)
38        .map(|hash| hash.to_string())
39        .map_err(|_| AppError::new(ErrorCode::Internal, "Password hashing failed"))
40}
41
42pub fn verify_password(password_hash: &str, password: &str) -> AppResult<bool> {
43    let parsed = PasswordHash::new(password_hash).map_err(|source| {
44        AppError::new(
45            ErrorCode::Internal,
46            format!("Stored password hash is invalid: {source}"),
47        )
48    })?;
49
50    Ok(Argon2::default()
51        .verify_password(password.as_bytes(), &parsed)
52        .is_ok())
53}
54
55pub fn new_session_token() -> String {
56    auth::public::new_session_token()
57}
58
59fn argon2_from_config(config: &AuthPasswordConfig) -> AppResult<Argon2<'static>> {
60    let params = Params::new(
61        config.argon2_memory_kib,
62        config.argon2_time_cost,
63        config.argon2_parallelism,
64        None,
65    )
66    .map_err(|_| AppError::new(ErrorCode::Internal, "Invalid password hash configuration"))?;
67
68    Ok(Argon2::new(
69        config.argon2_algorithm(),
70        Version::default(),
71        params,
72    ))
73}
74
75fn validation_error(field: &str, reason: &str) -> AppError {
76    AppError::validation(
77        "Request validation failed",
78        vec![ErrorDetail {
79            field: Some(field.to_owned()),
80            reason: reason.to_owned(),
81        }],
82    )
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn email_identifiers_are_trimmed_and_lowercased() {
91        assert_eq!(
92            normalize_identifier("  Ada@Example.COM  ").expect("identifier should normalize"),
93            "ada@example.com"
94        );
95    }
96
97    #[test]
98    fn phone_like_identifiers_are_trimmed_without_lowercasing() {
99        assert_eq!(
100            normalize_identifier("  +8613800000000  ").expect("identifier should normalize"),
101            "+8613800000000"
102        );
103    }
104
105    #[test]
106    fn password_hash_verifies_original_password_only() {
107        let hash = hash_password("correct horse", &AuthPasswordConfig::default())
108            .expect("password should hash");
109
110        assert!(verify_password(&hash, "correct horse").expect("hash should verify"));
111        assert!(!verify_password(&hash, "wrong horse").expect("hash should verify"));
112    }
113
114    #[test]
115    fn password_hash_uses_configured_argon2_policy() {
116        let config = AuthPasswordConfig {
117            hash_algorithm: crate::config::HashAlgorithm::Argon2i,
118            argon2_memory_kib: 16 * 1024,
119            argon2_time_cost: 3,
120            argon2_parallelism: 2,
121            ..AuthPasswordConfig::default()
122        };
123        let hash = hash_password("correct horse", &config).expect("password should hash");
124        let parsed = PasswordHash::new(&hash).expect("hash should parse");
125
126        assert_eq!(parsed.algorithm.to_string(), "argon2i");
127        assert_eq!(
128            parsed
129                .params
130                .get("m")
131                .and_then(|value| value.decimal().ok()),
132            Some(16 * 1024)
133        );
134        assert_eq!(
135            parsed
136                .params
137                .get("t")
138                .and_then(|value| value.decimal().ok()),
139            Some(3)
140        );
141        assert_eq!(
142            parsed
143                .params
144                .get("p")
145                .and_then(|value| value.decimal().ok()),
146            Some(2)
147        );
148    }
149
150    #[test]
151    fn session_tokens_are_random_enough_for_auth() {
152        let first = new_session_token();
153        let second = new_session_token();
154
155        assert!(first.starts_with("sess_"));
156        assert_eq!(first.len(), "sess_".len() + 64);
157        assert_ne!(first, second);
158    }
159}