alibi_core/utils/password/
mod.rs1use crate::error::{AuthError, AuthResult};
8use crate::plugin::AuthContext;
9use crate::schema::AuthSchema;
10use crate::types::UpdateUser;
11use async_trait::async_trait;
12use serde::Serialize;
13use std::fmt::Write;
14use std::sync::Arc;
15use unicode_normalization::UnicodeNormalization;
16
17#[async_trait]
26pub trait PasswordHasher: Send + Sync {
27 async fn hash(&self, password: &str) -> AuthResult<String>;
29 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool>;
31}
32
33#[derive(Clone, Debug)]
36pub struct PasswordHashContext {
37 pub path: Option<String>,
38 pub request: Option<crate::hooks::RequestHookContext>,
39}
40
41impl PasswordHashContext {
42 #[must_use]
43 pub fn from_request(request: crate::hooks::RequestHookContext) -> Self {
44 let path = request
45 .extensions
46 .get::<crate::plugin::ResolvedEndpoint>()
47 .map_or_else(|| request.path.clone(), |endpoint| endpoint.path.clone());
48 Self {
49 path: Some(path),
50 request: Some(request),
51 }
52 }
53}
54
55#[async_trait]
58pub trait PasswordHashHook: Send + Sync {
59 async fn before_hash(
60 &self,
61 password: &str,
62 context: Option<&PasswordHashContext>,
63 ) -> AuthResult<()>;
64}
65
66#[derive(Clone, Default)]
67pub(crate) struct PasswordHashHooks(pub Vec<Arc<dyn PasswordHashHook>>);
68
69#[derive(Clone, Default, Debug)]
71pub struct ScryptHasher;
72
73#[async_trait]
74impl PasswordHasher for ScryptHasher {
75 async fn hash(&self, password: &str) -> AuthResult<String> {
76 let password = password.to_owned();
77 tokio::task::spawn_blocking(move || {
78 let mut salt = [0; 16];
79 rand::fill(&mut salt);
80 let salt = hexadecimal(&salt);
81 let key = derive_scrypt(&password, &salt)?;
82 Ok(format!("{salt}:{}", hexadecimal(&key)))
83 })
84 .await
85 .map_err(|error| AuthError::PasswordHash(error.to_string()))?
86 }
87
88 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
89 let mut parts = hash.split(':');
90 let salt = parts
91 .next()
92 .filter(|salt| !salt.is_empty())
93 .ok_or_else(|| AuthError::PasswordHash("Invalid password hash".into()))?
94 .to_owned();
95 let expected = parts
96 .next()
97 .filter(|key| !key.is_empty())
98 .ok_or_else(|| AuthError::PasswordHash("Invalid password hash".into()))?
99 .to_owned();
100 let password = password.to_owned();
101 tokio::task::spawn_blocking(move || {
102 let actual = hexadecimal(&derive_scrypt(&password, &salt)?);
103 Ok(actual.len() == expected.len()
104 && actual
105 .bytes()
106 .zip(expected.bytes())
107 .fold(0u8, |different, (left, right)| different | (left ^ right))
108 == 0)
109 })
110 .await
111 .map_err(|error| AuthError::PasswordHash(error.to_string()))?
112 }
113}
114
115fn derive_scrypt(password: &str, salt: &str) -> AuthResult<[u8; 64]> {
116 let params = scrypt::Params::new(14, 16, 1)
117 .map_err(|error| AuthError::PasswordHash(error.to_string()))?;
118 let password = password.nfkc().collect::<String>();
119 let mut key = [0; 64];
120 scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut key)
122 .map_err(|error| AuthError::PasswordHash(error.to_string()))?;
123 Ok(key)
124}
125
126fn hexadecimal(bytes: &[u8]) -> String {
127 bytes.iter().fold(String::new(), |mut output, byte| {
128 _ = write!(output, "{byte:02x}");
129 output
130 })
131}
132
133pub async fn hash_password(
144 hasher: Option<&Arc<dyn PasswordHasher>>,
145 password: &str,
146) -> AuthResult<String> {
147 if let Some(hasher) = hasher {
148 return hasher.hash(password).await;
149 }
150
151 ScryptHasher.hash(password).await
152}
153
154pub async fn verify_password(
162 hasher: Option<&Arc<dyn PasswordHasher>>,
163 password: &str,
164 hash: &str,
165) -> AuthResult<()> {
166 if let Some(hasher) = hasher {
167 return {
168 let valid = hasher.verify(hash, password).await?;
169 if valid {
170 Ok(())
171 } else {
172 Err(AuthError::InvalidCredentials)
173 }
174 };
175 }
176
177 if ScryptHasher.verify(hash, password).await? {
178 Ok(())
179 } else {
180 Err(AuthError::InvalidCredentials)
181 }
182}
183
184pub fn validate_password(
197 password: &str,
198 min_length: usize,
199 max_length: usize,
200 ctx: &AuthContext<impl AuthSchema>,
201) -> AuthResult<()> {
202 let config = &ctx.config.password;
203
204 let length = password.encode_utf16().count();
205 if length < min_length {
206 _ = config;
207 return Err(AuthError::bad_request("Password too short"));
208 }
209
210 if length > max_length {
211 return Err(AuthError::bad_request("Password too long"));
212 }
213
214 if config.require_uppercase && !password.chars().any(char::is_uppercase) {
215 return Err(AuthError::bad_request(
216 "Password must contain at least one uppercase letter",
217 ));
218 }
219
220 if config.require_lowercase && !password.chars().any(char::is_lowercase) {
221 return Err(AuthError::bad_request(
222 "Password must contain at least one lowercase letter",
223 ));
224 }
225
226 if config.require_numbers && !password.chars().any(|c| c.is_ascii_digit()) {
227 return Err(AuthError::bad_request(
228 "Password must contain at least one number",
229 ));
230 }
231
232 if config.require_special
233 && !password
234 .chars()
235 .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))
236 {
237 return Err(AuthError::bad_request(
238 "Password must contain at least one special character",
239 ));
240 }
241
242 Ok(())
243}
244
245pub fn serialize_to_value(value: &impl Serialize) -> AuthResult<serde_json::Value> {
256 serde_json::to_value(value)
257 .map_err(|e| AuthError::internal(format!("Failed to serialize value: {e}")))
258}
259
260#[must_use]
266pub fn update_user_metadata(metadata: serde_json::Value) -> UpdateUser {
267 UpdateUser {
268 metadata: Some(metadata),
269 ..Default::default()
270 }
271}
272
273#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[tokio::test]
279 async fn scrypt_verifies_pinned_runtime_vectors_and_normalizes_unicode() {
280 let plain = "00112233445566778899aabbccddeeff:73122e887cfc14f91396cdef78dfe4b9dec28d459037601c4904cedba5a637ab97b39b40c236df5d42881d0109ca8cf0f85e39dbaba2911c190915bd8f30fe85";
282 let unicode = "00112233445566778899aabbccddeeff:d891432b268618420fb652f0515c0fcf5ae943c484f883e35d18a14c683ded87c0e469ab0cbdca84868fdb3680e7059018c0a6f90060eb0d866934dacfeae6a5";
283 assert!(verify_password(None, "password123", plain).await.is_ok());
284 assert!(matches!(
285 verify_password(None, "incorrect", plain).await,
286 Err(AuthError::InvalidCredentials)
287 ));
288 assert!(verify_password(None, "οΌ‘uth-Γ©-π", unicode).await.is_ok());
289 assert!(
290 verify_password(None, "Auth-e\u{301}-π", unicode)
291 .await
292 .is_ok()
293 );
294 }
295
296 #[tokio::test]
297 async fn scrypt_uses_random_hex_salt_and_rejects_malformed_or_changed_hashes() {
298 let first = hash_password(None, "password123").await.expect("hash");
299 let second = hash_password(None, "password123").await.expect("hash");
300 assert_ne!(first, second);
301 assert_eq!(first.len(), 32 + 1 + 128);
302 assert!(
303 first
304 .split(':')
305 .all(|part| part.bytes().all(|byte| byte.is_ascii_hexdigit()))
306 );
307 assert!(verify_password(None, "password123", &first).await.is_ok());
308 assert!(matches!(
309 verify_password(None, "password123", "bad").await,
310 Err(AuthError::PasswordHash(_))
311 ));
312 assert!(matches!(
313 verify_password(None, "password123", ":key").await,
314 Err(AuthError::PasswordHash(_))
315 ));
316 assert!(matches!(
317 verify_password(None, "password123", "salt:").await,
318 Err(AuthError::PasswordHash(_))
319 ));
320 let altered = format!("{first}0");
321 assert!(matches!(
322 verify_password(None, "password123", &altered).await,
323 Err(AuthError::InvalidCredentials)
324 ));
325 }
326
327 #[tokio::test]
328 async fn password_length_matches_utf16_code_units() {
329 let context = AuthContext::new(
330 Arc::new(crate::AuthConfig::new(
331 "password-tests-only-minimum-32-character-secret",
332 )),
333 crate::test_store::test_database().await,
334 );
335 assert!(validate_password("éééé", 8, 128, &context).is_err());
336 assert!(validate_password("ππππ", 8, 8, &context).is_ok());
337 assert!(validate_password("ππππ", 8, 7, &context).is_err());
338 }
339
340 #[tokio::test]
341 async fn test_password_validation() {
342 let mut config = crate::AuthConfig::new("test-secret");
343 config.password = crate::PasswordConfig {
344 require_uppercase: true,
345 require_lowercase: true,
346 require_numbers: true,
347 require_special: true,
348 };
349 let database = crate::test_store::test_database().await;
350 let ctx = AuthContext::new(Arc::new(config), database);
351
352 assert!(validate_password("Password123!", 8, usize::MAX, &ctx).is_ok());
354
355 assert!(validate_password("Pass1!", 8, usize::MAX, &ctx).is_err());
357
358 assert!(validate_password("password123!", 8, usize::MAX, &ctx).is_err());
360
361 assert!(validate_password("PASSWORD123!", 8, usize::MAX, &ctx).is_err());
363
364 assert!(validate_password("Password!", 8, usize::MAX, &ctx).is_err());
366
367 assert!(validate_password("Password123", 8, usize::MAX, &ctx).is_err());
369 }
370}
371