Skip to main content

renox_core/auth/
user.rs

1use std::future::Future;
2use std::sync::LazyLock;
3
4use argon2::Argon2;
5use argon2::password_hash::phc::PasswordHash;
6use argon2::password_hash::{PasswordHasher, PasswordVerifier};
7use serde::{Deserialize, Serialize};
8
9use super::Policy;
10use crate::db::{DateTime, Db, DbValue, Executor, FromRow, Model, Row, ToDbValue, sql};
11use crate::{Error, Result};
12
13/// A row of the `users` table created by the `Auth` module.
14///
15/// Columns the app adds with its own migration (a `role`, a `phone`) are
16/// kept in `extra`: read them with `user.get::<String>("role")`, change them
17/// with `user.set(&db, "role", "admin")`, filter with
18/// `User::where_eq("role", "admin")`. Templates and JSON see them as the
19/// user's own fields (`{{ auth.user.role }}`). A typed model on the same
20/// table (`#[model(table = "users")] struct Member`) works too.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct User {
24    /// The `users` row id; `0` before it is saved.
25    pub id: i64,
26    /// The display name.
27    pub name: String,
28    /// The email address (Renox's forms store it trimmed and lowercased).
29    pub email: String,
30    /// The Argon2 hash; never serialized, so it can't leak into templates or JSON.
31    #[serde(skip_serializing, default)]
32    pub password: String,
33    /// When the email address was confirmed; `None` while unverified.
34    pub email_verified_at: Option<DateTime>,
35    /// When the account was created.
36    pub created_at: Option<DateTime>,
37    /// When the row was last saved.
38    pub updated_at: Option<DateTime>,
39    /// The app's own columns, by name.
40    #[serde(flatten, default)]
41    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
42}
43
44/// Columns `extra` never holds: the struct's own, and secrets.
45const NOT_EXTRA: &[&str] = &[
46    "id",
47    "name",
48    "email",
49    "password",
50    "email_verified_at",
51    "created_at",
52    "updated_at",
53    "sessions_revoked_at",
54    "remember_token",
55    "session_revoked",
56];
57
58impl FromRow for User {
59    fn from_row(row: &Row) -> std::result::Result<Self, crate::db::DbError> {
60        let extra = row
61            .columns()
62            .into_iter()
63            .filter(|column| !NOT_EXTRA.contains(column))
64            .map(|column| (column.to_owned(), row.json(column)))
65            .collect();
66        Ok(Self {
67            id: row.try_get("id")?,
68            name: row.try_get("name")?,
69            email: row.try_get("email")?,
70            password: row.try_get("password")?,
71            email_verified_at: row.try_get("email_verified_at")?,
72            created_at: row.try_get("created_at")?,
73            updated_at: row.try_get("updated_at")?,
74            extra,
75        })
76    }
77}
78
79impl Model for User {
80    const TABLE: &'static str = "users";
81    const SELECT_ALL: bool = true;
82    const COLUMNS: &'static [&'static str] = &[
83        "id",
84        "name",
85        "email",
86        "password",
87        "email_verified_at",
88        "created_at",
89        "updated_at",
90    ];
91
92    type Key = i64;
93
94    fn id(&self) -> i64 {
95        self.id
96    }
97
98    fn set_id(&mut self, id: i64) {
99        self.id = id;
100    }
101
102    fn values(&self) -> Vec<DbValue> {
103        vec![
104            self.name.to_db_value(),
105            self.email.to_db_value(),
106            self.password.to_db_value(),
107            self.email_verified_at.to_db_value(),
108            self.created_at.to_db_value(),
109            self.updated_at.to_db_value(),
110        ]
111    }
112
113    fn touch(&mut self, now: DateTime, creating: bool) {
114        if creating && self.created_at.is_none() {
115            self.created_at = Some(now);
116        }
117        self.updated_at = Some(now);
118    }
119}
120
121impl User {
122    /// Emails are matched case-insensitively.
123    pub fn find_by_email<'c, E: Executor<'c>>(
124        db: E,
125        email: &str,
126    ) -> impl Future<Output = Result<Option<Self>>> + Send {
127        Self::query()
128            .where_eq("email", normalize_email(email))
129            .first(db)
130    }
131
132    /// Creates a user with a hashed password.
133    pub async fn register(db: &Db, name: &str, email: &str, password: &str) -> Result<Self> {
134        let user = Self {
135            name: name.trim().to_owned(),
136            email: normalize_email(email),
137            password: hash_password(password).await?,
138            ..Self::default()
139        };
140        let id = Self::create(db, user).await?.id;
141        // Read back, with the defaults of the app's own columns (`extra`).
142        Self::find_or_404(db, id).await
143    }
144
145    /// Changes the password. Every session of the user ends, this one too
146    /// (sessions hold a fingerprint of the password hash); in a handler use
147    /// [`crate::auth::change_password`], which logs this session in again.
148    pub async fn set_password(&mut self, db: &Db, password: &str) -> Result {
149        self.password = hash_password(password).await?;
150        self.save(db).await
151    }
152
153    /// One of the app's own columns (see `extra`), e.g.
154    /// `user.get::<String>("role")`; `None` if missing, null or of another type.
155    /// A BOOLEAN column reads as `bool` on SQLite too, where it is stored as
156    /// 0 or 1.
157    pub fn get<T: serde::de::DeserializeOwned>(&self, column: &str) -> Option<T> {
158        let value = self.extra.get(column)?;
159        serde_json::from_value(value.clone()).ok().or_else(|| {
160            let flag = value.as_i64().filter(|n| *n == 0 || *n == 1)?;
161            serde_json::from_value(serde_json::Value::Bool(flag == 1)).ok()
162        })
163    }
164
165    /// Sets one of the app's own columns in the database and in `extra`, e.g.
166    /// `user.set(&db, "role", "admin")`.
167    pub async fn set(&mut self, db: &Db, column: &str, value: impl ToDbValue) -> Result {
168        let plain = !column.is_empty()
169            && column
170                .chars()
171                .all(|c| c.is_ascii_alphanumeric() || c == '_');
172        if !plain || NOT_EXTRA.contains(&column) {
173            return Err(anyhow::anyhow!("User::set can't change `{column}`").into());
174        }
175        let value = value.to_db_value();
176        sql(format!(
177            "UPDATE users SET {} = ? WHERE id = ?",
178            crate::db::quote(column)
179        ))
180        .bind(value.clone())
181        .bind(self.id)
182        .execute(db)
183        .await?;
184        self.extra.insert(column.to_owned(), value.to_json());
185        Ok(())
186    }
187
188    /// Ends every session of the user, e.g. on logout or when an account
189    /// may be compromised. API tokens stay; see [`User::revoke_tokens`].
190    pub async fn revoke_sessions(&self, db: &Db) -> Result {
191        revoke_sessions(db, self.id).await.map(|_| ())
192    }
193
194    /// The user, when their sessions were last revoked, and whether the
195    /// session `session_id` was logged out, in one query.
196    pub(crate) async fn find_with_revocation(
197        db: &Db,
198        id: i64,
199        session_id: &str,
200    ) -> Result<Option<(Self, i64, bool)>> {
201        let row = sql(
202            "SELECT users.*, (SELECT COUNT(*) FROM revoked_sessions WHERE id = ?) AS session_revoked \
203             FROM users WHERE id = ?",
204        )
205        .bind(session_id)
206        .bind(id)
207        .fetch_optional(db)
208        .await?;
209        Ok(match row {
210            Some(row) => {
211                let revoked: i64 = row.try_get("session_revoked")?;
212                Some((
213                    Self::from_row(&row)?,
214                    row.try_get("sessions_revoked_at")?,
215                    revoked > 0,
216                ))
217            }
218            None => None,
219        })
220    }
221
222    /// The user with this email and password, e.g. to issue an API token.
223    /// Takes as long for an unknown email as for a wrong password, so the
224    /// answer doesn't reveal which emails have accounts.
225    pub async fn attempt(db: &Db, email: &str, password: &str) -> Result<Option<Self>> {
226        let user = Self::find_by_email(db, email).await?;
227        let hash = user
228            .as_ref()
229            .map_or_else(dummy_hash, |u| u.password.clone());
230        let valid = verify_password(password, &hash).await;
231        let Some(mut user) = user.filter(|_| valid) else {
232            return Ok(None);
233        };
234        user.rehash_if_needed(db, password).await?;
235        Ok(Some(user))
236    }
237
238    /// After a successful login: replaces an older hash (bcrypt from an
239    /// imported Laravel app) with Argon2id.
240    pub(crate) async fn rehash_if_needed(&mut self, db: &Db, password: &str) -> Result {
241        if needs_rehash(&self.password) {
242            self.set_password(db, password).await?;
243        }
244        Ok(())
245    }
246
247    /// Whether the user has a password. Users made by a social login
248    /// (`auth::register_verified`, the `renox-oauth` crate) don't: their
249    /// `password` is empty, which no typed password matches, until they
250    /// choose one with "Forgot your password?".
251    pub fn has_password(&self) -> bool {
252        !self.password.is_empty()
253    }
254
255    /// Whether `password` matches the stored hash (Argon2id or an imported bcrypt one).
256    pub async fn check_password(&self, password: &str) -> bool {
257        verify_password(password, &self.password).await
258    }
259
260    /// Whether `target`'s [`Policy`] allows this user `ability`.
261    pub fn can(&self, ability: &str, target: &impl Policy) -> bool {
262        target.allows(self, ability)
263    }
264
265    /// Like [`User::can`], but a refusal becomes a 403 error.
266    pub fn authorize(&self, ability: &str, target: &impl Policy) -> Result {
267        if self.can(ability, target) {
268            Ok(())
269        } else {
270            Err(Error::Forbidden)
271        }
272    }
273}
274
275/// Ends every session of user `id` logged in until now; returns the cut-off.
276pub(crate) async fn revoke_sessions(db: &Db, id: i64) -> Result<i64> {
277    let now = super::unix_millis();
278    sql("UPDATE users SET sessions_revoked_at = ? WHERE id = ?")
279        .bind(now)
280        .bind(id)
281        .execute(db)
282        .await?;
283    Ok(now)
284}
285
286/// Emails are stored and looked up trimmed and lowercased, so they match
287/// regardless of case on every database (SQLite's `COLLATE NOCASE` alone
288/// wouldn't help on PostgreSQL).
289pub(crate) fn normalize_email(email: &str) -> String {
290    email.trim().to_lowercase()
291}
292
293/// A real hash of a throwaway password, verified against when an email is
294/// unknown so the response takes as long as for a real account.
295pub(crate) fn dummy_hash() -> String {
296    static HASH: LazyLock<String> = LazyLock::new(|| {
297        Argon2::default()
298            .hash_password(b"renox-timing-equaliser")
299            .map(|hash| hash.to_string())
300            .unwrap_or_default()
301    });
302    HASH.clone()
303}
304
305/// Hashes a password with Argon2id on a blocking thread.
306pub async fn hash_password(password: &str) -> Result<String> {
307    let password = password.to_owned();
308    tokio::task::spawn_blocking(move || {
309        Argon2::default()
310            .hash_password(password.as_bytes())
311            .map(|hash| hash.to_string())
312            .map_err(|err| anyhow::anyhow!("could not hash the password: {err}"))
313    })
314    .await
315    .map_err(anyhow::Error::from)?
316    .map_err(Error::from)
317}
318
319/// Checks a password against its hash on a blocking thread: Argon2 (what
320/// Renox writes), or bcrypt (`$2y$…`, e.g. users imported from Laravel).
321pub async fn verify_password(password: &str, hash: &str) -> bool {
322    let (password, hash) = (password.to_owned(), hash.to_owned());
323    tokio::task::spawn_blocking(move || {
324        if is_bcrypt(&hash) {
325            // PHP writes `$2y$`, the same algorithm as `$2b$`.
326            let hash = hash.replacen("$2y$", "$2b$", 1);
327            return bcrypt::verify(password.as_bytes(), &hash).unwrap_or(false);
328        }
329        PasswordHash::new(&hash).is_ok_and(|parsed| {
330            Argon2::default()
331                .verify_password(password.as_bytes(), &parsed)
332                .is_ok()
333        })
334    })
335    .await
336    .unwrap_or(false)
337}
338
339fn is_bcrypt(hash: &str) -> bool {
340    ["$2y$", "$2b$", "$2a$"].iter().any(|p| hash.starts_with(p))
341}
342
343/// Whether a hash should be replaced by a fresh Argon2id one (e.g. a bcrypt
344/// hash imported from Laravel). The login does it with the typed password.
345pub fn needs_rehash(hash: &str) -> bool {
346    !hash.starts_with("$argon2id$")
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[tokio::test]
354    async fn hashes_verify() {
355        let hash = hash_password("rahasia123").await.unwrap();
356        assert!(hash.starts_with("$argon2id$"));
357        assert!(verify_password("rahasia123", &hash).await);
358        assert!(!verify_password("salah", &hash).await);
359        assert!(!verify_password("rahasia123", "not a hash").await);
360        assert!(!needs_rehash(&hash));
361    }
362
363    #[tokio::test]
364    async fn laravel_bcrypt_hashes_verify() {
365        // `Hash::make('password')` in Laravel (cost 4 to keep the test fast).
366        let laravel = bcrypt::hash("password", 4)
367            .unwrap()
368            .replacen("$2b$", "$2y$", 1);
369        assert!(laravel.starts_with("$2y$04$"));
370        assert!(verify_password("password", &laravel).await);
371        assert!(!verify_password("wrong", &laravel).await);
372        assert!(needs_rehash(&laravel));
373    }
374}