alibi_core/utils/password/
mod.rs1use crate::error::{AuthError, AuthResult};
4use crate::plugin::AuthContext;
5use crate::schema::AuthSchema;
6use crate::types::UpdateUser;
7use async_trait::async_trait;
8use serde::Serialize;
9use std::fmt::Write;
10use std::sync::Arc;
11use unicode_normalization::UnicodeNormalization;
12
13#[async_trait]
18pub trait PasswordHasher: Send + Sync {
19 async fn hash(&self, password: &str) -> AuthResult<String>;
21 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool>;
23}
24
25#[derive(Clone, Debug)]
28pub struct PasswordHashContext {
29 pub path: Option<String>,
30 pub request: Option<crate::hooks::RequestHookContext>,
31}
32
33impl PasswordHashContext {
34 #[must_use]
35 pub fn from_request(request: crate::hooks::RequestHookContext) -> Self {
36 let path = request
37 .extensions
38 .get::<crate::plugin::ResolvedEndpoint>()
39 .map_or_else(|| request.path.clone(), |endpoint| endpoint.path.clone());
40 Self {
41 path: Some(path),
42 request: Some(request),
43 }
44 }
45}
46
47#[async_trait]
50pub trait PasswordHashHook: Send + Sync {
51 async fn before_hash(
52 &self,
53 password: &str,
54 context: Option<&PasswordHashContext>,
55 ) -> AuthResult<()>;
56}
57
58#[derive(Clone, Default)]
59pub(crate) struct PasswordHashHooks(pub Vec<Arc<dyn PasswordHashHook>>);
60
61#[derive(Clone, Default, Debug)]
63pub struct ScryptHasher;
64
65#[async_trait]
66impl PasswordHasher for ScryptHasher {
67 async fn hash(&self, password: &str) -> AuthResult<String> {
68 let password = password.to_owned();
69 tokio::task::spawn_blocking(move || {
70 let mut salt = [0; 16];
71 rand::fill(&mut salt);
72 let salt = hexadecimal(&salt);
73 let key = derive_scrypt(&password, &salt)?;
74 Ok(format!("{salt}:{}", hexadecimal(&key)))
75 })
76 .await
77 .map_err(|error| AuthError::PasswordHash(error.to_string()))?
78 }
79
80 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
81 let mut parts = hash.split(':');
82 let salt = parts
83 .next()
84 .filter(|salt| !salt.is_empty())
85 .ok_or_else(|| AuthError::PasswordHash("Invalid password hash".into()))?
86 .to_owned();
87 let expected = parts
88 .next()
89 .filter(|key| !key.is_empty())
90 .ok_or_else(|| AuthError::PasswordHash("Invalid password hash".into()))?
91 .to_owned();
92 let password = password.to_owned();
93 tokio::task::spawn_blocking(move || {
94 let actual = hexadecimal(&derive_scrypt(&password, &salt)?);
95 Ok(actual.len() == expected.len()
96 && actual
97 .bytes()
98 .zip(expected.bytes())
99 .fold(0u8, |different, (left, right)| different | (left ^ right))
100 == 0)
101 })
102 .await
103 .map_err(|error| AuthError::PasswordHash(error.to_string()))?
104 }
105}
106
107fn derive_scrypt(password: &str, salt: &str) -> AuthResult<[u8; 64]> {
108 let params = scrypt::Params::new(14, 16, 1)
109 .map_err(|error| AuthError::PasswordHash(error.to_string()))?;
110 let password = password.nfkc().collect::<String>();
111 let mut key = [0; 64];
112 scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut key)
114 .map_err(|error| AuthError::PasswordHash(error.to_string()))?;
115 Ok(key)
116}
117
118fn hexadecimal(bytes: &[u8]) -> String {
119 bytes.iter().fold(String::new(), |mut output, byte| {
120 _ = write!(output, "{byte:02x}");
121 output
122 })
123}
124
125pub async fn hash_password(
132 hasher: Option<&Arc<dyn PasswordHasher>>,
133 password: &str,
134) -> AuthResult<String> {
135 match hasher {
136 Some(hasher) => hasher.hash(password).await,
137 None => ScryptHasher.hash(password).await,
138 }
139}
140
141pub async fn verify_password(
149 hasher: Option<&Arc<dyn PasswordHasher>>,
150 password: &str,
151 hash: &str,
152) -> AuthResult<()> {
153 let valid = match hasher {
154 Some(hasher) => hasher.verify(hash, password).await?,
155 None => ScryptHasher.verify(hash, password).await?,
156 };
157 if valid {
158 Ok(())
159 } else {
160 Err(AuthError::InvalidCredentials)
161 }
162}
163
164pub fn validate_password(
173 password: &str,
174 min_length: usize,
175 max_length: usize,
176 ctx: &AuthContext<impl AuthSchema>,
177) -> AuthResult<()> {
178 let config = &ctx.config.password;
179
180 let length = password.encode_utf16().count();
181 if length < min_length {
182 return Err(AuthError::bad_request("Password too short"));
183 }
184
185 if length > max_length {
186 return Err(AuthError::bad_request("Password too long"));
187 }
188
189 if config.require_uppercase && !password.chars().any(char::is_uppercase) {
190 return Err(AuthError::bad_request(
191 "Password must contain at least one uppercase letter",
192 ));
193 }
194
195 if config.require_lowercase && !password.chars().any(char::is_lowercase) {
196 return Err(AuthError::bad_request(
197 "Password must contain at least one lowercase letter",
198 ));
199 }
200
201 if config.require_numbers && !password.chars().any(|c| c.is_ascii_digit()) {
202 return Err(AuthError::bad_request(
203 "Password must contain at least one number",
204 ));
205 }
206
207 if config.require_special
208 && !password
209 .chars()
210 .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))
211 {
212 return Err(AuthError::bad_request(
213 "Password must contain at least one special character",
214 ));
215 }
216
217 Ok(())
218}
219
220pub fn serialize_to_value(value: &impl Serialize) -> AuthResult<serde_json::Value> {
227 serde_json::to_value(value)
228 .map_err(|e| AuthError::internal(format!("Failed to serialize value: {e}")))
229}
230
231#[must_use]
233pub fn update_user_metadata(metadata: serde_json::Value) -> UpdateUser {
234 UpdateUser {
235 metadata: Some(metadata),
236 ..Default::default()
237 }
238}
239
240#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[tokio::test]
246 async fn scrypt_verifies_pinned_runtime_vectors_and_normalizes_unicode() {
247 let plain = "00112233445566778899aabbccddeeff:73122e887cfc14f91396cdef78dfe4b9dec28d459037601c4904cedba5a637ab97b39b40c236df5d42881d0109ca8cf0f85e39dbaba2911c190915bd8f30fe85";
249 let unicode = "00112233445566778899aabbccddeeff:d891432b268618420fb652f0515c0fcf5ae943c484f883e35d18a14c683ded87c0e469ab0cbdca84868fdb3680e7059018c0a6f90060eb0d866934dacfeae6a5";
250 assert!(verify_password(None, "password123", plain).await.is_ok());
251 assert!(matches!(
252 verify_password(None, "incorrect", plain).await,
253 Err(AuthError::InvalidCredentials)
254 ));
255 assert!(verify_password(None, "οΌ‘uth-Γ©-π", unicode).await.is_ok());
256 assert!(
257 verify_password(None, "Auth-e\u{301}-π", unicode)
258 .await
259 .is_ok()
260 );
261 }
262
263 #[tokio::test]
264 async fn scrypt_uses_random_hex_salt_and_rejects_malformed_or_changed_hashes() {
265 let first = hash_password(None, "password123").await.expect("hash");
266 let second = hash_password(None, "password123").await.expect("hash");
267 assert_ne!(first, second);
268 assert_eq!(first.len(), 32 + 1 + 128);
269 assert!(
270 first
271 .split(':')
272 .all(|part| part.bytes().all(|byte| byte.is_ascii_hexdigit()))
273 );
274 assert!(verify_password(None, "password123", &first).await.is_ok());
275 assert!(matches!(
276 verify_password(None, "password123", "bad").await,
277 Err(AuthError::PasswordHash(_))
278 ));
279 assert!(matches!(
280 verify_password(None, "password123", ":key").await,
281 Err(AuthError::PasswordHash(_))
282 ));
283 assert!(matches!(
284 verify_password(None, "password123", "salt:").await,
285 Err(AuthError::PasswordHash(_))
286 ));
287 let altered = format!("{first}0");
288 assert!(matches!(
289 verify_password(None, "password123", &altered).await,
290 Err(AuthError::InvalidCredentials)
291 ));
292 }
293
294 #[tokio::test]
295 async fn password_length_matches_utf16_code_units() {
296 let context = AuthContext::new(
297 Arc::new(crate::AuthConfig::new(
298 "password-tests-only-minimum-32-character-secret",
299 )),
300 crate::test_store::test_database().await,
301 );
302 assert!(validate_password("éééé", 8, 128, &context).is_err());
303 assert!(validate_password("ππππ", 8, 8, &context).is_ok());
304 assert!(validate_password("ππππ", 8, 7, &context).is_err());
305 }
306
307 #[tokio::test]
308 async fn test_password_validation() {
309 let mut config = crate::AuthConfig::new("test-secret");
310 config.password = crate::PasswordConfig {
311 require_uppercase: true,
312 require_lowercase: true,
313 require_numbers: true,
314 require_special: true,
315 };
316 let database = crate::test_store::test_database().await;
317 let ctx = AuthContext::new(Arc::new(config), database);
318
319 assert!(validate_password("Password123!", 8, usize::MAX, &ctx).is_ok());
321
322 assert!(validate_password("Pass1!", 8, usize::MAX, &ctx).is_err());
324
325 assert!(validate_password("password123!", 8, usize::MAX, &ctx).is_err());
327
328 assert!(validate_password("PASSWORD123!", 8, usize::MAX, &ctx).is_err());
330
331 assert!(validate_password("Password!", 8, usize::MAX, &ctx).is_err());
333
334 assert!(validate_password("Password123", 8, usize::MAX, &ctx).is_err());
336 }
337}
338