use std::collections::HashMap;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use async_trait::async_trait;
use parking_lot::RwLock;
use rand::rngs::OsRng;
use thiserror::Error;
use crate::credentials::MIN_PASSWORD_LENGTH;
#[derive(Debug, Error)]
pub enum UserStoreError {
#[error("password hash: {0}")]
Hash(String),
#[error("backend: {0}")]
Backend(String),
#[error("password must be at least {min_length} characters")]
PasswordTooShort {
min_length: usize,
},
#[error("not implemented")]
NotImplemented,
}
#[derive(Debug, Clone)]
pub struct User {
pub id: String,
pub email: String,
pub webid: String,
pub name: Option<String>,
pub password_hash: String,
}
#[async_trait]
pub trait UserStore: Send + Sync + 'static {
async fn find_by_email(&self, email: &str) -> Result<Option<User>, UserStoreError>;
async fn find_by_id(&self, id: &str) -> Result<Option<User>, UserStoreError>;
async fn verify_password(
&self,
user: &User,
password: &str,
) -> Result<bool, UserStoreError> {
let parsed = PasswordHash::new(&user.password_hash)
.map_err(|e| UserStoreError::Hash(e.to_string()))?;
let ok = Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok();
Ok(ok)
}
async fn delete(&self, _id: &str) -> Result<bool, UserStoreError> {
Err(UserStoreError::NotImplemented)
}
}
#[derive(Default)]
pub struct InMemoryUserStore {
inner: RwLock<HashMap<String, User>>,
}
impl InMemoryUserStore {
pub fn new() -> Self {
Self::default()
}
pub fn insert_user(
&self,
id: impl Into<String>,
email: impl Into<String>,
webid: impl Into<String>,
name: Option<String>,
password: &str,
) -> Result<User, UserStoreError> {
if password.len() < MIN_PASSWORD_LENGTH {
return Err(UserStoreError::PasswordTooShort {
min_length: MIN_PASSWORD_LENGTH,
});
}
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| UserStoreError::Hash(e.to_string()))?
.to_string();
let user = User {
id: id.into(),
email: email.into().to_ascii_lowercase(),
webid: webid.into(),
name,
password_hash: hash,
};
self.inner.write().insert(user.email.clone(), user.clone());
Ok(user)
}
}
#[async_trait]
impl UserStore for InMemoryUserStore {
async fn find_by_email(&self, email: &str) -> Result<Option<User>, UserStoreError> {
Ok(self.inner.read().get(&email.to_ascii_lowercase()).cloned())
}
async fn find_by_id(&self, id: &str) -> Result<Option<User>, UserStoreError> {
Ok(self
.inner
.read()
.values()
.find(|u| u.id == id)
.cloned())
}
async fn delete(&self, id: &str) -> Result<bool, UserStoreError> {
let mut guard = self.inner.write();
let email_key = guard
.iter()
.find(|(_, u)| u.id == id)
.map(|(k, _)| k.clone());
match email_key {
Some(k) => {
guard.remove(&k);
Ok(true)
}
None => Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn inmemory_stores_and_verifies() {
let store = InMemoryUserStore::new();
let user = store
.insert_user(
"u-1",
"Ada@Example.COM",
"https://ada.example/profile#me",
Some("Ada".into()),
"correct-horse-battery-staple",
)
.unwrap();
assert_eq!(user.email, "ada@example.com");
let found = store.find_by_email("ada@example.com").await.unwrap().unwrap();
assert_eq!(found.id, "u-1");
let found2 = store.find_by_email("ADA@example.COM").await.unwrap().unwrap();
assert_eq!(found2.id, "u-1");
assert!(store.verify_password(&found, "correct-horse-battery-staple").await.unwrap());
assert!(!store.verify_password(&found, "wrong-password").await.unwrap());
}
#[tokio::test]
async fn inmemory_delete_removes_user() {
let store = InMemoryUserStore::new();
store
.insert_user(
"u-del",
"del@example.com",
"https://del.example/profile#me",
None,
"password",
)
.unwrap();
assert!(store.find_by_id("u-del").await.unwrap().is_some());
let removed = store.delete("u-del").await.unwrap();
assert!(removed, "first delete should return true");
assert!(store.find_by_id("u-del").await.unwrap().is_none());
let removed_again = store.delete("u-del").await.unwrap();
assert!(!removed_again, "second delete should return false");
}
#[tokio::test]
async fn inmemory_find_by_id() {
let store = InMemoryUserStore::new();
store
.insert_user(
"u-2",
"bob@example.com",
"https://bob.example/profile#me",
None,
"password",
)
.unwrap();
let found = store.find_by_id("u-2").await.unwrap().unwrap();
assert_eq!(found.email, "bob@example.com");
assert!(store.find_by_id("missing").await.unwrap().is_none());
}
#[test]
fn insert_user_rejects_7_char_password() {
let store = InMemoryUserStore::new();
let err = store
.insert_user(
"u-short",
"short@example.com",
"https://short.example/profile#me",
None,
"1234567",
)
.unwrap_err();
match err {
UserStoreError::PasswordTooShort { min_length } => {
assert_eq!(min_length, 8);
}
other => panic!("expected PasswordTooShort, got {other:?}"),
}
}
#[test]
fn insert_user_accepts_8_char_password() {
let store = InMemoryUserStore::new();
let user = store
.insert_user(
"u-ok",
"ok@example.com",
"https://ok.example/profile#me",
None,
"12345678",
)
.unwrap();
assert_eq!(user.id, "u-ok");
}
#[test]
fn insert_user_rejects_empty_password() {
let store = InMemoryUserStore::new();
let err = store
.insert_user(
"u-empty",
"empty@example.com",
"https://empty.example/profile#me",
None,
"",
)
.unwrap_err();
match err {
UserStoreError::PasswordTooShort { min_length } => {
assert_eq!(min_length, 8);
}
other => panic!("expected PasswordTooShort, got {other:?}"),
}
}
}