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