1use std::collections::HashMap;
9
10use argon2::password_hash::SaltString;
11use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
12use async_trait::async_trait;
13use parking_lot::RwLock;
14use rand::rngs::OsRng;
15use thiserror::Error;
16
17use crate::credentials::MIN_PASSWORD_LENGTH;
18
19#[derive(Debug, Error)]
21pub enum UserStoreError {
22 #[error("password hash: {0}")]
24 Hash(String),
25
26 #[error("backend: {0}")]
28 Backend(String),
29
30 #[error("password must be at least {min_length} characters")]
33 PasswordTooShort {
34 min_length: usize,
36 },
37
38 #[error("not implemented")]
42 NotImplemented,
43}
44
45#[derive(Debug, Clone)]
47pub struct User {
48 pub id: String,
50 pub email: String,
52 pub webid: String,
54 pub name: Option<String>,
56 pub password_hash: String,
58}
59
60#[async_trait]
62pub trait UserStore: Send + Sync + 'static {
63 async fn find_by_email(&self, email: &str) -> Result<Option<User>, UserStoreError>;
66
67 async fn find_by_id(&self, id: &str) -> Result<Option<User>, UserStoreError>;
69
70 async fn verify_password(
75 &self,
76 user: &User,
77 password: &str,
78 ) -> Result<bool, UserStoreError> {
79 let parsed = PasswordHash::new(&user.password_hash)
80 .map_err(|e| UserStoreError::Hash(e.to_string()))?;
81 let ok = Argon2::default()
82 .verify_password(password.as_bytes(), &parsed)
83 .is_ok();
84 Ok(ok)
85 }
86
87 async fn delete(&self, _id: &str) -> Result<bool, UserStoreError> {
96 Err(UserStoreError::NotImplemented)
97 }
98}
99
100#[derive(Default)]
102pub struct InMemoryUserStore {
103 inner: RwLock<HashMap<String, User>>,
104}
105
106impl InMemoryUserStore {
107 pub fn new() -> Self {
109 Self::default()
110 }
111
112 pub fn insert_user(
120 &self,
121 id: impl Into<String>,
122 email: impl Into<String>,
123 webid: impl Into<String>,
124 name: Option<String>,
125 password: &str,
126 ) -> Result<User, UserStoreError> {
127 if password.len() < MIN_PASSWORD_LENGTH {
128 return Err(UserStoreError::PasswordTooShort {
129 min_length: MIN_PASSWORD_LENGTH,
130 });
131 }
132 let salt = SaltString::generate(&mut OsRng);
133 let hash = Argon2::default()
134 .hash_password(password.as_bytes(), &salt)
135 .map_err(|e| UserStoreError::Hash(e.to_string()))?
136 .to_string();
137 let user = User {
138 id: id.into(),
139 email: email.into().to_ascii_lowercase(),
140 webid: webid.into(),
141 name,
142 password_hash: hash,
143 };
144 self.inner.write().insert(user.email.clone(), user.clone());
145 Ok(user)
146 }
147}
148
149#[async_trait]
150impl UserStore for InMemoryUserStore {
151 async fn find_by_email(&self, email: &str) -> Result<Option<User>, UserStoreError> {
152 Ok(self.inner.read().get(&email.to_ascii_lowercase()).cloned())
153 }
154
155 async fn find_by_id(&self, id: &str) -> Result<Option<User>, UserStoreError> {
156 Ok(self
157 .inner
158 .read()
159 .values()
160 .find(|u| u.id == id)
161 .cloned())
162 }
163
164 async fn delete(&self, id: &str) -> Result<bool, UserStoreError> {
165 let mut guard = self.inner.write();
166 let email_key = guard
168 .iter()
169 .find(|(_, u)| u.id == id)
170 .map(|(k, _)| k.clone());
171 match email_key {
172 Some(k) => {
173 guard.remove(&k);
174 Ok(true)
175 }
176 None => Ok(false),
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[tokio::test]
186 async fn inmemory_stores_and_verifies() {
187 let store = InMemoryUserStore::new();
188 let user = store
189 .insert_user(
190 "u-1",
191 "Ada@Example.COM",
192 "https://ada.example/profile#me",
193 Some("Ada".into()),
194 "correct-horse-battery-staple",
195 )
196 .unwrap();
197 assert_eq!(user.email, "ada@example.com");
198
199 let found = store.find_by_email("ada@example.com").await.unwrap().unwrap();
200 assert_eq!(found.id, "u-1");
201
202 let found2 = store.find_by_email("ADA@example.COM").await.unwrap().unwrap();
204 assert_eq!(found2.id, "u-1");
205
206 assert!(store.verify_password(&found, "correct-horse-battery-staple").await.unwrap());
207 assert!(!store.verify_password(&found, "wrong-password").await.unwrap());
208 }
209
210 #[tokio::test]
211 async fn inmemory_delete_removes_user() {
212 let store = InMemoryUserStore::new();
213 store
214 .insert_user(
215 "u-del",
216 "del@example.com",
217 "https://del.example/profile#me",
218 None,
219 "password",
220 )
221 .unwrap();
222 assert!(store.find_by_id("u-del").await.unwrap().is_some());
223
224 let removed = store.delete("u-del").await.unwrap();
225 assert!(removed, "first delete should return true");
226 assert!(store.find_by_id("u-del").await.unwrap().is_none());
227
228 let removed_again = store.delete("u-del").await.unwrap();
229 assert!(!removed_again, "second delete should return false");
230 }
231
232 #[tokio::test]
233 async fn inmemory_find_by_id() {
234 let store = InMemoryUserStore::new();
235 store
236 .insert_user(
237 "u-2",
238 "bob@example.com",
239 "https://bob.example/profile#me",
240 None,
241 "password",
242 )
243 .unwrap();
244 let found = store.find_by_id("u-2").await.unwrap().unwrap();
245 assert_eq!(found.email, "bob@example.com");
246 assert!(store.find_by_id("missing").await.unwrap().is_none());
247 }
248
249 #[test]
252 fn insert_user_rejects_7_char_password() {
253 let store = InMemoryUserStore::new();
254 let err = store
255 .insert_user(
256 "u-short",
257 "short@example.com",
258 "https://short.example/profile#me",
259 None,
260 "1234567",
261 )
262 .unwrap_err();
263 match err {
264 UserStoreError::PasswordTooShort { min_length } => {
265 assert_eq!(min_length, 8);
266 }
267 other => panic!("expected PasswordTooShort, got {other:?}"),
268 }
269 }
270
271 #[test]
272 fn insert_user_accepts_8_char_password() {
273 let store = InMemoryUserStore::new();
274 let user = store
275 .insert_user(
276 "u-ok",
277 "ok@example.com",
278 "https://ok.example/profile#me",
279 None,
280 "12345678",
281 )
282 .unwrap();
283 assert_eq!(user.id, "u-ok");
284 }
285
286 #[test]
287 fn insert_user_rejects_empty_password() {
288 let store = InMemoryUserStore::new();
289 let err = store
290 .insert_user(
291 "u-empty",
292 "empty@example.com",
293 "https://empty.example/profile#me",
294 None,
295 "",
296 )
297 .unwrap_err();
298 match err {
299 UserStoreError::PasswordTooShort { min_length } => {
300 assert_eq!(min_length, 8);
301 }
302 other => panic!("expected PasswordTooShort, got {other:?}"),
303 }
304 }
305}