Skip to main content

rust_web_server/auth_flows/
store.rs

1//! Where accounts live: [`UserStore`], in memory or in a database table.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6/// An account as the auth flows see it.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct AuthUser {
9    /// Your user id, as text (`"42"`, a UUID).
10    pub id: String,
11    /// Lower case, trimmed.
12    pub email: String,
13    /// Argon2id PHC string ([`crypto::hash_password`](crate::crypto::hash_password)).
14    pub password_hash: String,
15    /// Whether the email address was confirmed.
16    pub confirmed: bool,
17}
18
19/// Accounts: implement it over your users table, or use [`MemoryUserStore`]
20/// / [`DbUserStore`]. Errors are messages; the flows answer them `500`.
21/// The methods are synchronous: the flows run as a middleware, on the
22/// blocking pool.
23pub trait UserStore: Send + Sync {
24    /// The account with this email (already lower-cased), if any.
25    fn find_by_email(&self, email: &str) -> Result<Option<AuthUser>, String>;
26    /// The account with this id, if any.
27    fn find_by_id(&self, id: &str) -> Result<Option<AuthUser>, String>;
28    /// Create an account (not confirmed). `Ok(None)` when the email is taken.
29    fn create(&self, email: &str, password_hash: &str) -> Result<Option<AuthUser>, String>;
30    /// Replace the password hash.
31    fn set_password_hash(&self, id: &str, password_hash: &str) -> Result<(), String>;
32    /// Mark the email address confirmed.
33    fn confirm(&self, id: &str) -> Result<(), String>;
34}
35
36/// Accounts in memory: tests and trying things out. Clones share them.
37#[derive(Clone, Default)]
38pub struct MemoryUserStore {
39    users: Arc<Mutex<HashMap<String, AuthUser>>>,
40}
41
42impl MemoryUserStore {
43    pub fn new() -> Self {
44        MemoryUserStore::default()
45    }
46
47    /// Every account, by id.
48    pub fn users(&self) -> Vec<AuthUser> {
49        let mut users: Vec<AuthUser> = self.users.lock().unwrap_or_else(|e| e.into_inner()).values().cloned().collect();
50        users.sort_by(|a, b| a.id.cmp(&b.id));
51        users
52    }
53}
54
55impl UserStore for MemoryUserStore {
56    fn find_by_email(&self, email: &str) -> Result<Option<AuthUser>, String> {
57        Ok(self.users.lock().unwrap_or_else(|e| e.into_inner()).values().find(|u| u.email == email).cloned())
58    }
59
60    fn find_by_id(&self, id: &str) -> Result<Option<AuthUser>, String> {
61        Ok(self.users.lock().unwrap_or_else(|e| e.into_inner()).get(id).cloned())
62    }
63
64    fn create(&self, email: &str, password_hash: &str) -> Result<Option<AuthUser>, String> {
65        let mut users = self.users.lock().unwrap_or_else(|e| e.into_inner());
66        if users.values().any(|u| u.email == email) {
67            return Ok(None);
68        }
69        let id = (users.len() + 1).to_string();
70        let user = AuthUser { id: id.clone(), email: email.to_string(), password_hash: password_hash.to_string(), confirmed: false };
71        users.insert(id, user.clone());
72        Ok(Some(user))
73    }
74
75    fn set_password_hash(&self, id: &str, password_hash: &str) -> Result<(), String> {
76        match self.users.lock().unwrap_or_else(|e| e.into_inner()).get_mut(id) {
77            Some(user) => {
78                user.password_hash = password_hash.to_string();
79                Ok(())
80            }
81            None => Err(format!("no user {id}")),
82        }
83    }
84
85    fn confirm(&self, id: &str) -> Result<(), String> {
86        match self.users.lock().unwrap_or_else(|e| e.into_inner()).get_mut(id) {
87            Some(user) => {
88                user.confirmed = true;
89                Ok(())
90            }
91            None => Err(format!("no user {id}")),
92        }
93    }
94}
95
96/// Accounts in the `rws_users` table of a [`DbPool`](crate::model::DbPool)
97/// (`model-*` features), created if absent:
98///
99/// ```sql
100/// CREATE TABLE rws_users (
101///     id            VARCHAR(64)  PRIMARY KEY,   -- 32 hex digits
102///     email         VARCHAR(191) NOT NULL UNIQUE,
103///     password_hash TEXT         NOT NULL,
104///     confirmed     INTEGER      NOT NULL DEFAULT 0,
105///     created_at    BIGINT       NOT NULL
106/// )
107/// ```
108///
109/// The store's calls wait for the database on the calling thread (the
110/// flows run on the blocking pool).
111#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
112#[derive(Clone)]
113pub struct DbUserStore {
114    pool: crate::model::DbPool,
115}
116
117#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
118impl DbUserStore {
119    /// Use `pool`, creating `rws_users` if it isn't there.
120    pub async fn new(pool: crate::model::DbPool) -> Result<Self, crate::model::DbError> {
121        pool.execute(
122            "CREATE TABLE IF NOT EXISTS rws_users (id VARCHAR(64) PRIMARY KEY, email VARCHAR(191) NOT NULL UNIQUE, \
123             password_hash TEXT NOT NULL, confirmed INTEGER NOT NULL DEFAULT 0, created_at BIGINT NOT NULL)",
124            &[],
125        )
126        .await?;
127        Ok(DbUserStore { pool })
128    }
129
130    pub fn pool(&self) -> &crate::model::DbPool {
131        &self.pool
132    }
133
134    fn sql(&self, sql: &str) -> String {
135        #[cfg(feature = "model-postgres")]
136        if self.pool.backend() == crate::model::Backend::Postgres {
137            let mut out = String::new();
138            let mut n = 0;
139            for c in sql.chars() {
140                if c == '?' {
141                    n += 1;
142                    out.push_str(&format!("${n}"));
143                } else {
144                    out.push(c);
145                }
146            }
147            return out;
148        }
149        sql.to_string()
150    }
151
152    fn run<T: Send + 'static>(&self, future: impl std::future::Future<Output = Result<T, crate::model::DbError>> + Send + 'static) -> Result<T, String> {
153        rws_server::async_bridge::block_on_shared(future).map_err(|e| e.to_string())
154    }
155
156    fn one(&self, sql: &str, params: Vec<crate::model::Value>) -> Result<Option<AuthUser>, String> {
157        let (pool, sql) = (self.pool.clone(), self.sql(sql));
158        let rows = self.run(async move { pool.query_rows(&sql, &params).await })?;
159        let Some(row) = rows.first() else { return Ok(None) };
160        let user = (|| -> Result<AuthUser, crate::model::DbError> {
161            Ok(AuthUser { id: row.get("id")?, email: row.get("email")?, password_hash: row.get("password_hash")?, confirmed: row.get::<i64>("confirmed")? != 0 })
162        })()
163        .map_err(|e| e.to_string())?;
164        Ok(Some(user))
165    }
166
167    fn exec(&self, sql: &str, params: Vec<crate::model::Value>) -> Result<u64, String> {
168        let (pool, sql) = (self.pool.clone(), self.sql(sql));
169        self.run(async move { pool.execute(&sql, &params).await })
170    }
171}
172
173#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
174impl UserStore for DbUserStore {
175    fn find_by_email(&self, email: &str) -> Result<Option<AuthUser>, String> {
176        self.one("SELECT id, email, password_hash, confirmed FROM rws_users WHERE email = ?", vec![crate::model::Value::Text(email.to_string())])
177    }
178
179    fn find_by_id(&self, id: &str) -> Result<Option<AuthUser>, String> {
180        self.one("SELECT id, email, password_hash, confirmed FROM rws_users WHERE id = ?", vec![crate::model::Value::Text(id.to_string())])
181    }
182
183    fn create(&self, email: &str, password_hash: &str) -> Result<Option<AuthUser>, String> {
184        use crate::model::Value;
185        if self.find_by_email(email)?.is_some() {
186            return Ok(None);
187        }
188        let id = crate::crypto::generate_token(16);
189        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
190        let inserted = self.exec(
191            "INSERT INTO rws_users (id, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, 0, ?)",
192            vec![Value::Text(id.clone()), Value::Text(email.to_string()), Value::Text(password_hash.to_string()), Value::Int(now)],
193        );
194        match inserted {
195            Ok(_) => Ok(Some(AuthUser { id, email: email.to_string(), password_hash: password_hash.to_string(), confirmed: false })),
196            // The UNIQUE index: another sign-up took the email in between.
197            Err(_) if self.find_by_email(email)?.is_some() => Ok(None),
198            Err(e) => Err(e),
199        }
200    }
201
202    fn set_password_hash(&self, id: &str, password_hash: &str) -> Result<(), String> {
203        use crate::model::Value;
204        self.exec("UPDATE rws_users SET password_hash = ? WHERE id = ?", vec![Value::Text(password_hash.to_string()), Value::Text(id.to_string())]).map(|_| ())
205    }
206
207    fn confirm(&self, id: &str) -> Result<(), String> {
208        use crate::model::Value;
209        self.exec("UPDATE rws_users SET confirmed = 1 WHERE id = ?", vec![Value::Text(id.to_string())]).map(|_| ())
210    }
211}