Skip to main content

allowthem_core/
users.rs

1use chrono::{DateTime, Utc};
2use serde::Serialize;
3use serde_json::Value;
4
5use crate::db::Db;
6use crate::error::AuthError;
7use crate::password::hash_password;
8use crate::types::{Email, User, UserId, Username};
9
10/// Map a SQLite UNIQUE constraint violation to `AuthError::Conflict`.
11///
12/// SQLite UNIQUE violations include the constraint name in the message,
13/// e.g. "UNIQUE constraint failed: allowthem_users.email".
14pub(crate) fn map_unique_violation(err: sqlx::Error) -> AuthError {
15    if let sqlx::Error::Database(ref db_err) = err {
16        let msg = db_err.message();
17        if msg.contains("UNIQUE constraint failed") {
18            if msg.contains("email") {
19                return AuthError::Conflict("email already exists".into());
20            }
21            if msg.contains("username") {
22                return AuthError::Conflict("username already exists".into());
23            }
24            return AuthError::Conflict(msg.to_string());
25        }
26    }
27    AuthError::Database(err)
28}
29
30/// Parameters for searching/filtering users in the admin directory.
31pub struct SearchUsersParams<'a> {
32    pub query: Option<&'a str>,
33    pub is_active: Option<bool>,
34    pub has_mfa: Option<bool>,
35    pub limit: u32,
36    pub offset: u32,
37}
38
39/// User with MFA enrollment status, for list display.
40#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
41pub struct UserListEntry {
42    pub id: UserId,
43    pub email: Email,
44    pub username: Option<Username>,
45    pub is_active: bool,
46    pub has_mfa: bool,
47    pub created_at: DateTime<Utc>,
48}
49
50/// Result of a paginated user search.
51pub struct SearchUsersResult {
52    pub users: Vec<UserListEntry>,
53    pub total: u32,
54}
55
56impl Db {
57    /// Create a user with email, plaintext password, optional username, and optional custom data.
58    ///
59    /// Hashes the password with Argon2id (via `password::hash_password`).
60    /// Returns the created User (without password_hash in the returned struct).
61    pub async fn create_user(
62        &self,
63        email: Email,
64        password: &str,
65        username: Option<Username>,
66        custom_data: Option<&Value>,
67    ) -> Result<User, AuthError> {
68        let id = UserId::new();
69        let pw_hash = hash_password(password)?;
70        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
71
72        sqlx::query(
73            "INSERT INTO allowthem_users \
74             (id, email, username, password_hash, email_verified, is_active, created_at, updated_at, custom_data) \
75             VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5, ?6)",
76        )
77        .bind(id)
78        .bind(&email)
79        .bind(&username)
80        .bind(&pw_hash)
81        .bind(&now)
82        .bind(custom_data.map(sqlx::types::Json))
83        .execute(self.pool())
84        .await
85        .map_err(map_unique_violation)?;
86
87        self.get_user(id).await
88    }
89
90    /// Import a user with a pre-existing password hash (for migration from external systems).
91    /// The hash must be a valid Argon2 PHC string. No validation is performed on it.
92    pub async fn create_user_with_hash(
93        &self,
94        email: Email,
95        password_hash: &str,
96        username: Option<Username>,
97        custom_data: Option<&Value>,
98    ) -> Result<User, AuthError> {
99        let id = UserId::new();
100        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
101
102        sqlx::query(
103            "INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at, custom_data)
104             VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5, ?6)",
105        )
106        .bind(id)
107        .bind(&email)
108        .bind(&username)
109        .bind(password_hash)
110        .bind(&now)
111        .bind(custom_data.map(sqlx::types::Json))
112        .execute(self.pool())
113        .await
114        .map_err(map_unique_violation)?;
115
116        self.get_user(id).await
117    }
118
119    /// Look up a user by ID. Returns User with password_hash = None.
120    pub async fn get_user(&self, id: UserId) -> Result<User, AuthError> {
121        sqlx::query_as::<_, User>(
122            "SELECT id, email, username, NULL as password_hash, \
123             email_verified, is_active, created_at, updated_at, custom_data \
124             FROM allowthem_users WHERE id = ?",
125        )
126        .bind(id)
127        .fetch_optional(self.pool())
128        .await?
129        .ok_or(AuthError::NotFound)
130    }
131
132    /// Look up a user by email. Returns User with password_hash = None.
133    pub async fn get_user_by_email(&self, email: &Email) -> Result<User, AuthError> {
134        sqlx::query_as::<_, User>(
135            "SELECT id, email, username, NULL as password_hash, \
136             email_verified, is_active, created_at, updated_at, custom_data \
137             FROM allowthem_users WHERE email = ?",
138        )
139        .bind(email)
140        .fetch_optional(self.pool())
141        .await?
142        .ok_or(AuthError::NotFound)
143    }
144
145    /// Look up a user by username. Returns User with password_hash = None.
146    pub async fn get_user_by_username(&self, username: &Username) -> Result<User, AuthError> {
147        sqlx::query_as::<_, User>(
148            "SELECT id, email, username, NULL as password_hash, \
149             email_verified, is_active, created_at, updated_at, custom_data \
150             FROM allowthem_users WHERE username = ?",
151        )
152        .bind(username)
153        .fetch_optional(self.pool())
154        .await?
155        .ok_or(AuthError::NotFound)
156    }
157
158    /// Look up a user by email OR username for login.
159    ///
160    /// Returns User WITH password_hash populated. The caller is responsible
161    /// for calling `verify_password()` to check the password.
162    pub async fn find_for_login(&self, identifier: &str) -> Result<User, AuthError> {
163        sqlx::query_as::<_, User>(
164            "SELECT id, email, username, password_hash, \
165             email_verified, is_active, created_at, updated_at, custom_data \
166             FROM allowthem_users WHERE email = ?1 OR username = ?1",
167        )
168        .bind(identifier)
169        .fetch_optional(self.pool())
170        .await?
171        .ok_or(AuthError::NotFound)
172    }
173
174    /// Update a user's email. Also updates updated_at.
175    pub async fn update_user_email(&self, id: UserId, email: Email) -> Result<(), AuthError> {
176        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
177        let result =
178            sqlx::query("UPDATE allowthem_users SET email = ?1, updated_at = ?2 WHERE id = ?3")
179                .bind(&email)
180                .bind(&now)
181                .bind(id)
182                .execute(self.pool())
183                .await
184                .map_err(map_unique_violation)?;
185
186        if result.rows_affected() == 0 {
187            return Err(AuthError::NotFound);
188        }
189        Ok(())
190    }
191
192    /// Update a user's username (set or clear). Also updates updated_at.
193    pub async fn update_user_username(
194        &self,
195        id: UserId,
196        username: Option<Username>,
197    ) -> Result<(), AuthError> {
198        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
199        let result =
200            sqlx::query("UPDATE allowthem_users SET username = ?1, updated_at = ?2 WHERE id = ?3")
201                .bind(&username)
202                .bind(&now)
203                .bind(id)
204                .execute(self.pool())
205                .await
206                .map_err(map_unique_violation)?;
207
208        if result.rows_affected() == 0 {
209            return Err(AuthError::NotFound);
210        }
211        Ok(())
212    }
213
214    /// Update a user's is_active flag. Also updates updated_at.
215    pub async fn update_user_active(&self, id: UserId, is_active: bool) -> Result<(), AuthError> {
216        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
217        let result =
218            sqlx::query("UPDATE allowthem_users SET is_active = ?1, updated_at = ?2 WHERE id = ?3")
219                .bind(is_active)
220                .bind(&now)
221                .bind(id)
222                .execute(self.pool())
223                .await?;
224
225        if result.rows_affected() == 0 {
226            return Err(AuthError::NotFound);
227        }
228        Ok(())
229    }
230
231    /// Delete a user by ID. Cascades to sessions, user_roles, user_permissions.
232    pub async fn delete_user(&self, id: UserId) -> Result<(), AuthError> {
233        let result = sqlx::query("DELETE FROM allowthem_users WHERE id = ?")
234            .bind(id)
235            .execute(self.pool())
236            .await?;
237
238        if result.rows_affected() == 0 {
239            return Err(AuthError::NotFound);
240        }
241        Ok(())
242    }
243
244    /// List all users ordered by `created_at ASC`. Returns User with `password_hash = None`.
245    pub async fn list_users(&self) -> Result<Vec<User>, AuthError> {
246        sqlx::query_as::<_, User>(
247            "SELECT id, email, username, NULL as password_hash, \
248             email_verified, is_active, created_at, updated_at, custom_data \
249             FROM allowthem_users ORDER BY created_at ASC",
250        )
251        .fetch_all(self.pool())
252        .await
253        .map_err(AuthError::Database)
254    }
255
256    /// Search and filter users with pagination.
257    ///
258    /// Builds a dynamic query with optional search term (matched against
259    /// email and username via LIKE), status filter, and MFA filter.
260    /// Returns matching users with their MFA enrollment status.
261    pub async fn search_users(
262        &self,
263        params: SearchUsersParams<'_>,
264    ) -> Result<SearchUsersResult, AuthError> {
265        let mut where_clauses: Vec<String> = Vec::new();
266        let mut bind_values: Vec<String> = Vec::new();
267
268        if let Some(q) = params.query {
269            let trimmed = q.trim();
270            if !trimmed.is_empty() {
271                let escaped = trimmed
272                    .replace('\\', "\\\\")
273                    .replace('%', "\\%")
274                    .replace('_', "\\_");
275                let pattern = format!("%{escaped}%");
276                where_clauses
277                    .push("(u.email LIKE ? ESCAPE '\\' OR u.username LIKE ? ESCAPE '\\')".into());
278                bind_values.push(pattern.clone());
279                bind_values.push(pattern);
280            }
281        }
282
283        if let Some(active) = params.is_active {
284            where_clauses.push("u.is_active = ?".into());
285            bind_values.push(if active { "1".into() } else { "0".into() });
286        }
287
288        if let Some(has_mfa) = params.has_mfa {
289            let exists = if has_mfa { "EXISTS" } else { "NOT EXISTS" };
290            where_clauses.push(format!(
291                "{exists} (SELECT 1 FROM allowthem_mfa_secrets WHERE user_id = u.id AND enabled = 1)"
292            ));
293        }
294
295        let where_sql = if where_clauses.is_empty() {
296            String::new()
297        } else {
298            format!("WHERE {}", where_clauses.join(" AND "))
299        };
300
301        let count_sql: &'static str = Box::leak(
302            format!("SELECT COUNT(*) FROM allowthem_users u {where_sql}").into_boxed_str(),
303        );
304        let mut count_query = sqlx::query_scalar::<_, i64>(count_sql);
305        for val in &bind_values {
306            count_query = count_query.bind(val);
307        }
308        let total = count_query
309            .fetch_one(self.pool())
310            .await
311            .map_err(AuthError::Database)? as u32;
312
313        let data_sql: &'static str = Box::leak(
314            format!(
315                "SELECT u.id, u.email, u.username, u.is_active, \
316                 EXISTS (SELECT 1 FROM allowthem_mfa_secrets \
317                         WHERE user_id = u.id AND enabled = 1) as has_mfa, \
318                 u.created_at \
319                 FROM allowthem_users u {where_sql} \
320                 ORDER BY u.created_at ASC \
321                 LIMIT ? OFFSET ?"
322            )
323            .into_boxed_str(),
324        );
325        let mut data_query = sqlx::query_as::<_, UserListEntry>(data_sql);
326        for val in &bind_values {
327            data_query = data_query.bind(val);
328        }
329        data_query = data_query.bind(params.limit).bind(params.offset);
330
331        let users = data_query
332            .fetch_all(self.pool())
333            .await
334            .map_err(AuthError::Database)?;
335
336        Ok(SearchUsersResult { users, total })
337    }
338
339    /// Update a user's password. Hashes `new_password` with Argon2id and stores it.
340    ///
341    /// Returns `AuthError::NotFound` if no user with `id` exists.
342    pub async fn update_user_password(
343        &self,
344        id: UserId,
345        new_password: &str,
346    ) -> Result<(), AuthError> {
347        let pw_hash = hash_password(new_password)?;
348        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
349        let result = sqlx::query(
350            "UPDATE allowthem_users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3",
351        )
352        .bind(&pw_hash)
353        .bind(&now)
354        .bind(id)
355        .execute(self.pool())
356        .await?;
357
358        if result.rows_affected() == 0 {
359            return Err(AuthError::NotFound);
360        }
361        Ok(())
362    }
363
364    /// Set a user's password hash to NULL.
365    ///
366    /// Used by admin force-password-reset to invalidate the current password.
367    /// The login flow falls back to a dummy hash when `password_hash` is NULL,
368    /// so `verify_password` will always fail.
369    pub async fn clear_password_hash(&self, id: UserId) -> Result<(), AuthError> {
370        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
371        let result = sqlx::query(
372            "UPDATE allowthem_users SET password_hash = NULL, updated_at = ? WHERE id = ?",
373        )
374        .bind(&now)
375        .bind(id)
376        .execute(self.pool())
377        .await?;
378
379        if result.rows_affected() == 0 {
380            return Err(AuthError::NotFound);
381        }
382        Ok(())
383    }
384
385    /// Get a user's custom data.
386    ///
387    /// Returns `Err(NotFound)` if no user with `id` exists.
388    /// Returns `Ok(None)` if the user exists but has no custom data.
389    pub async fn get_custom_data(&self, id: &UserId) -> Result<Option<Value>, AuthError> {
390        let row: Option<(Option<Value>,)> =
391            sqlx::query_as("SELECT custom_data FROM allowthem_users WHERE id = ?")
392                .bind(id)
393                .fetch_optional(self.pool())
394                .await?;
395
396        match row {
397            None => Err(AuthError::NotFound),
398            Some((data,)) => Ok(data),
399        }
400    }
401
402    /// Set a user's custom data. Also updates `updated_at`.
403    ///
404    /// Returns `Err(NotFound)` if no user with `id` exists.
405    pub async fn set_custom_data(&self, id: &UserId, data: &Value) -> Result<(), AuthError> {
406        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
407        let result = sqlx::query(
408            "UPDATE allowthem_users SET custom_data = ?1, updated_at = ?2 WHERE id = ?3",
409        )
410        .bind(sqlx::types::Json(data))
411        .bind(&now)
412        .bind(id)
413        .execute(self.pool())
414        .await?;
415
416        if result.rows_affected() == 0 {
417            return Err(AuthError::NotFound);
418        }
419        Ok(())
420    }
421
422    /// Delete (clear) a user's custom data by setting it to NULL. Also updates `updated_at`.
423    ///
424    /// Idempotent -- succeeds even if custom data is already NULL.
425    pub async fn delete_custom_data(&self, id: &UserId) -> Result<(), AuthError> {
426        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
427        sqlx::query("UPDATE allowthem_users SET custom_data = NULL, updated_at = ?1 WHERE id = ?2")
428            .bind(&now)
429            .bind(id)
430            .execute(self.pool())
431            .await?;
432
433        Ok(())
434    }
435}