auth_password/
password.rs1use 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}