1use async_trait::async_trait;
2use argon2::password_hash::rand_core::OsRng;
3use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
4use argon2::Argon2;
5use secrets_core::auth::{AuthError, AuthMethod, AuthOutcome, AuthResult, LoginRequest};
6use secrets_core::storage::{StorageBackend, StorageEntry};
7use serde::{Deserialize, Serialize};
8
9const USER_PREFIX: &str = "auth/userpass/users/";
10const DEFAULT_TTL_SECONDS: i64 = 3600;
11
12pub const MAX_USERNAME_LEN: usize = 64;
14pub const MIN_PASSWORD_LEN: usize = 12;
18
19#[derive(Debug, Serialize, Deserialize)]
20struct UserRecord {
21 password_hash: String,
22 policies: Vec<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct UserInfo {
30 pub username: String,
31 pub policies: Vec<String>,
32}
33
34pub struct UserPassAuth;
35
36impl Default for UserPassAuth {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl UserPassAuth {
43 pub fn new() -> Self {
44 Self
45 }
46
47 pub fn hash_password(password: &str) -> AuthResult<String> {
48 let salt = SaltString::generate(&mut OsRng);
49 Argon2::default()
50 .hash_password(password.as_bytes(), &salt)
51 .map(|h| h.to_string())
52 .map_err(|e| AuthError::Other(e.to_string()))
53 }
54
55 pub async fn user_exists(storage: &dyn StorageBackend, username: &str) -> AuthResult<bool> {
56 Ok(storage.get(&user_key(username)).await?.is_some())
57 }
58
59 pub async fn create_user(
66 storage: &dyn StorageBackend,
67 username: &str,
68 password: &str,
69 policies: Vec<String>,
70 ) -> AuthResult<()> {
71 put_user(storage, username, password, policies).await
72 }
73
74 pub async fn upsert_user(
81 storage: &dyn StorageBackend,
82 username: &str,
83 password: &str,
84 policies: Vec<String>,
85 ) -> AuthResult<()> {
86 validate_username(username)?;
87 validate_password(password)?;
88 validate_policies(&policies)?;
89 put_user(storage, username, password, policies).await
90 }
91
92 pub async fn read_user(
95 storage: &dyn StorageBackend,
96 username: &str,
97 ) -> AuthResult<Option<UserInfo>> {
98 let Some(record) = get_record(storage, username).await? else {
99 return Ok(None);
100 };
101 Ok(Some(UserInfo {
102 username: username.to_string(),
103 policies: record.policies,
104 }))
105 }
106
107 pub async fn delete_user(storage: &dyn StorageBackend, username: &str) -> AuthResult<bool> {
112 let key = user_key(username);
113 if storage.get(&key).await?.is_none() {
114 return Ok(false);
115 }
116 storage.delete(&key).await?;
117 Ok(true)
118 }
119}
120
121pub fn validate_username(username: &str) -> AuthResult<()> {
126 if username.is_empty() || username.len() > MAX_USERNAME_LEN {
127 return Err(AuthError::InvalidRequest(format!(
128 "username must be 1 to {MAX_USERNAME_LEN} characters"
129 )));
130 }
131 if !username
132 .chars()
133 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
134 {
135 return Err(AuthError::InvalidRequest(
136 "username may only contain letters, digits, '_', '.' and '-'".into(),
137 ));
138 }
139 if username.starts_with('.') {
140 return Err(AuthError::InvalidRequest(
141 "username must not start with '.'".into(),
142 ));
143 }
144 Ok(())
145}
146
147pub fn validate_password(password: &str) -> AuthResult<()> {
150 if password.chars().count() < MIN_PASSWORD_LEN {
151 return Err(AuthError::InvalidRequest(format!(
152 "password must be at least {MIN_PASSWORD_LEN} characters"
153 )));
154 }
155 Ok(())
156}
157
158pub fn validate_policies(policies: &[String]) -> AuthResult<()> {
161 if policies.iter().any(|p| p.is_empty()) {
162 return Err(AuthError::InvalidRequest(
163 "policy names must be non-empty".into(),
164 ));
165 }
166 Ok(())
167}
168
169fn user_key(username: &str) -> String {
170 format!("{USER_PREFIX}{username}")
171}
172
173async fn put_user(
174 storage: &dyn StorageBackend,
175 username: &str,
176 password: &str,
177 policies: Vec<String>,
178) -> AuthResult<()> {
179 let record = UserRecord {
180 password_hash: UserPassAuth::hash_password(password)?,
181 policies,
182 };
183 let value = serde_json::to_vec(&record).map_err(|e| AuthError::Other(e.to_string()))?;
184 storage
185 .put(
186 &user_key(username),
187 StorageEntry {
188 value,
189 expires_at: None,
190 },
191 )
192 .await?;
193 Ok(())
194}
195
196async fn get_record(storage: &dyn StorageBackend, username: &str) -> AuthResult<Option<UserRecord>> {
197 let Some(entry) = storage.get(&user_key(username)).await? else {
198 return Ok(None);
199 };
200 let record = serde_json::from_slice(&entry.value).map_err(|e| AuthError::Other(e.to_string()))?;
201 Ok(Some(record))
202}
203
204#[async_trait]
205impl AuthMethod for UserPassAuth {
206 async fn login(
207 &self,
208 storage: &dyn StorageBackend,
209 request: LoginRequest,
210 ) -> AuthResult<AuthOutcome> {
211 let LoginRequest::UserPass { username, password } = request else {
212 return Err(AuthError::InvalidRequest(
213 "expected username/password".into(),
214 ));
215 };
216
217 let record = get_record(storage, &username)
218 .await?
219 .ok_or(AuthError::InvalidCredentials)?;
220
221 let hash = PasswordHash::new(&record.password_hash)
222 .map_err(|e| AuthError::Other(e.to_string()))?;
223 Argon2::default()
224 .verify_password(password.as_bytes(), &hash)
225 .map_err(|_| AuthError::InvalidCredentials)?;
226
227 Ok(AuthOutcome {
228 policies: record.policies,
229 display_name: username,
230 ttl_seconds: Some(DEFAULT_TTL_SECONDS),
231 })
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use secrets_core::storage::StorageResult;
239 use std::collections::HashMap;
240 use std::sync::Mutex;
241
242 #[derive(Default)]
243 struct MemStorage(Mutex<HashMap<String, StorageEntry>>);
244
245 #[async_trait]
246 impl StorageBackend for MemStorage {
247 async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
248 Ok(self.0.lock().unwrap().get(path).cloned())
249 }
250 async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
251 self.0.lock().unwrap().insert(path.to_string(), entry);
252 Ok(())
253 }
254 async fn delete(&self, path: &str) -> StorageResult<()> {
255 self.0.lock().unwrap().remove(path);
256 Ok(())
257 }
258 async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
259 Ok(self
260 .0
261 .lock()
262 .unwrap()
263 .keys()
264 .filter(|k| k.starts_with(prefix))
265 .cloned()
266 .collect())
267 }
268 }
269
270 const PASSWORD: &str = "correct horse battery";
271
272 async fn login(storage: &MemStorage, username: &str, password: &str) -> AuthResult<AuthOutcome> {
273 UserPassAuth::new()
274 .login(
275 storage,
276 LoginRequest::UserPass {
277 username: username.to_string(),
278 password: password.to_string(),
279 },
280 )
281 .await
282 }
283
284 fn names(names: &[&str]) -> Vec<String> {
285 names.iter().map(|n| n.to_string()).collect()
286 }
287
288 #[tokio::test]
289 async fn upserted_user_logs_in_with_exactly_its_policies() {
290 let storage = MemStorage::default();
291 UserPassAuth::upsert_user(&storage, "liaison", PASSWORD, names(&["liaison"]))
292 .await
293 .unwrap();
294
295 let outcome = login(&storage, "liaison", PASSWORD).await.unwrap();
296 assert_eq!(outcome.policies, names(&["liaison"]));
297 assert_eq!(outcome.display_name, "liaison");
298 assert!(storage.0.lock().unwrap().contains_key("auth/userpass/users/liaison"));
300 }
301
302 #[tokio::test]
305 async fn upsert_replaces_password_and_policies() {
306 let storage = MemStorage::default();
307 UserPassAuth::upsert_user(&storage, "app", PASSWORD, names(&["a", "b"]))
308 .await
309 .unwrap();
310 UserPassAuth::upsert_user(&storage, "app", "another long password", names(&["c"]))
311 .await
312 .unwrap();
313
314 assert!(matches!(
315 login(&storage, "app", PASSWORD).await,
316 Err(AuthError::InvalidCredentials)
317 ));
318 let outcome = login(&storage, "app", "another long password").await.unwrap();
319 assert_eq!(outcome.policies, names(&["c"]));
320 }
321
322 #[tokio::test]
323 async fn read_user_returns_policies_and_never_the_hash() {
324 let storage = MemStorage::default();
325 UserPassAuth::upsert_user(&storage, "app", PASSWORD, names(&["p"]))
326 .await
327 .unwrap();
328
329 let info = UserPassAuth::read_user(&storage, "app").await.unwrap().unwrap();
330 assert_eq!(
331 info,
332 UserInfo {
333 username: "app".into(),
334 policies: names(&["p"]),
335 }
336 );
337 let json = serde_json::to_string(&info).unwrap();
338 assert!(!json.contains("hash"), "{json}");
339 assert!(!json.contains("argon2"), "{json}");
340
341 assert_eq!(UserPassAuth::read_user(&storage, "nobody").await.unwrap(), None);
342 }
343
344 #[tokio::test]
345 async fn delete_user_reports_whether_it_existed_and_blocks_login() {
346 let storage = MemStorage::default();
347 UserPassAuth::upsert_user(&storage, "app", PASSWORD, vec![])
348 .await
349 .unwrap();
350
351 assert!(UserPassAuth::delete_user(&storage, "app").await.unwrap());
352 assert!(!UserPassAuth::delete_user(&storage, "app").await.unwrap());
353 assert!(matches!(
354 login(&storage, "app", PASSWORD).await,
355 Err(AuthError::InvalidCredentials)
356 ));
357 }
358
359 #[tokio::test]
362 async fn create_user_skips_validation_for_bootstrap() {
363 let storage = MemStorage::default();
364 UserPassAuth::create_user(&storage, "admin@example.com", "change-me", names(&["root"]))
365 .await
366 .unwrap();
367 let outcome = login(&storage, "admin@example.com", "change-me").await.unwrap();
368 assert_eq!(outcome.policies, names(&["root"]));
369 }
370
371 #[tokio::test]
372 async fn invalid_upsert_writes_nothing() {
373 let storage = MemStorage::default();
374 for (username, password, policies) in [
375 (".hidden", PASSWORD, vec![]),
376 ("app", "too short", vec![]),
377 ("app", PASSWORD, names(&["ok", ""])),
378 ] {
379 let result = UserPassAuth::upsert_user(&storage, username, password, policies).await;
380 assert!(
381 matches!(result, Err(AuthError::InvalidRequest(_))),
382 "{username}/{password} was accepted"
383 );
384 }
385 assert!(storage.0.lock().unwrap().is_empty());
386 }
387
388 #[test]
389 fn username_rules() {
390 for ok in ["a", "typednotes-app", "svc_1.v2", "A-Z.0_9", &"x".repeat(64), "a."] {
391 assert!(validate_username(ok).is_ok(), "{ok:?} rejected");
392 }
393 for bad in [
394 "",
395 &"x".repeat(65),
396 ".",
397 ".env",
398 "a/b",
399 "../root",
400 "with space",
401 "émile",
402 "a@b",
403 "a%2Fb",
404 ] {
405 assert!(
406 matches!(validate_username(bad), Err(AuthError::InvalidRequest(_))),
407 "{bad:?} accepted"
408 );
409 }
410 }
411
412 #[test]
413 fn password_length_counts_characters() {
414 assert!(validate_password("12345678901").is_err());
415 assert!(validate_password("123456789012").is_ok());
416 assert!(validate_password("ééééééééééé").is_err());
418 }
419
420 #[test]
421 fn policy_names_must_be_non_empty_but_the_list_may_be_empty() {
422 assert!(validate_policies(&[]).is_ok());
423 assert!(validate_policies(&names(&["a", "b"])).is_ok());
424 assert!(validate_policies(&names(&[""])).is_err());
425 }
426}