Skip to main content

tablo_core/auth/
password.rs

1//! The shipped credentials: the [`AdminUser`] model, Argon2id hashing, and the
2//! [`PasswordAuth`] authenticator over them.
3
4use std::sync::{Arc, LazyLock};
5
6use jiff::Timestamp;
7use tokio::sync::Semaphore;
8use topcoat::context::Cx;
9use uuid::Uuid;
10
11use super::{
12    Authenticator, EmailChange, EmailVerifier, PanelUser, PasswordResetter, Profile, ProfileEditor,
13    ProfileUpdate, Registrar, SignUp, infrastructure_failure, profile::CURRENT_PASSWORD_FIELD,
14    request_email_change,
15};
16use crate::FieldErrors;
17
18/// A dummy Argon2id PHC string verified against when the account does not
19/// exist, so unknown logins pay the same work as known ones (ADR-0013).
20/// Generated with `Argon2::default()` parameters (`m=19456,t=2,p=1`).
21const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$h3oXdPBVwhcgZ1OTO/PuzQ$zLrHLgIkwhqu4ZlLTfSyB8mPuL6mAtaswv/eXJ5ADO8";
22
23/// Shipped credential model with unique email, Argon2id PHC hash, display name,
24/// and active flag; belongs to no tenant. [`PasswordAuth`] stores the email, and looks it up,
25/// trimmed and lowercased, so one address holds one account whatever case it is typed in; a
26/// resource that edits the email must store it the same way.
27/// Its [`ModelColumns`](crate::ModelColumns) leave out the hash, so a resource over the panel's
28/// admins lists them. A resource's edit form that changes an admin's email or password hash ends
29/// that admin's sessions, pending sign-ins, reset links and pending email change in the same
30/// transaction; a custom action that changes them does not, and calls
31/// [`revoke_sessions_for_user`](super::revoke_sessions_for_user) itself; one that changes `active`
32/// to `false` ends them at the admin's next request.
33#[derive(Debug, Clone, toasty::Model, crate::ModelColumns)]
34pub struct AdminUser {
35    #[key]
36    #[auto]
37    pub id: Uuid,
38    #[unique]
39    pub email: String,
40    /// Argon2id hash in PHC string format; never stores the plaintext.
41    #[columns(skip)]
42    pub password_hash: String,
43    pub display_name: String,
44    /// `false` denies login and panel access immediately.
45    pub active: bool,
46    /// When the account proved it reads `email`; `None` until it does, and again after the
47    /// email changes. Read only by a panel with
48    /// [`Auth::email_verification`](super::Auth::email_verification).
49    pub email_verified_at: Option<Timestamp>,
50    pub created_at: Timestamp,
51}
52
53impl PanelUser for AdminUser {
54    fn user_id(&self) -> String {
55        self.id.to_string()
56    }
57
58    fn display_name(&self) -> &str {
59        &self.display_name
60    }
61
62    fn email(&self) -> Option<&str> {
63        Some(&self.email)
64    }
65
66    fn can_access_panel(&self, _panel: &str) -> bool {
67        self.active
68    }
69}
70
71/// Ends the sessions, pending sign-ins, reset links and pending email change of the
72/// [`AdminUser`] a panel write changed, through `ex`, when the write changed its email or its
73/// password hash, on every panel whose authenticator loads `AdminUser`s. An admin editing their
74/// own account moves to a fresh session, as on the profile page. Any other model, and an edit
75/// that leaves both alone, passes through.
76pub(crate) async fn end_changed_credentials(
77    cx: &Cx,
78    ex: &mut dyn toasty::Executor,
79    before: &(dyn std::any::Any + Send + Sync),
80    after: &(dyn std::any::Any + Send + Sync),
81) -> topcoat::Result<()> {
82    let (Some(before), Some(after)) = (
83        before.downcast_ref::<AdminUser>(),
84        after.downcast_ref::<AdminUser>(),
85    ) else {
86        return Ok(());
87    };
88    if before.email == after.email && before.password_hash == after.password_hash {
89        return Ok(());
90    }
91    let Some(panel) = super::realm::current_realm(cx) else {
92        return Ok(());
93    };
94    let user_type = std::any::TypeId::of::<AdminUser>();
95    let panels = super::session::credential_panels(cx, user_type, panel);
96    if panels.is_empty() {
97        return Ok(());
98    }
99    let user_id = before.id.to_string();
100    if let Some(signed) = super::signed(cx).filter(|signed| {
101        signed.user.user_id() == user_id && panels.iter().any(|p| Arc::ptr_eq(p, &signed.realm))
102    }) {
103        let session = super::session::start(cx).await?;
104        return super::session::replace_sessions(cx, ex, signed, user_type, &session).await;
105    }
106    super::session::end_credentials(ex, &user_id, &panels, super::token::REVOCATION).await
107}
108
109/// Shipped default authenticator: Argon2id verification against [`AdminUser`].
110///
111/// An unknown email costs the same work as a wrong password, and every failure shows the same
112/// message. It stores an email trimmed and lowercased and looks it up the same way, so
113/// `Ada@Example.com` and `ada@example.com` are one account; a resource that edits an admin's
114/// email stores it the same way. A row stored with capitals cannot sign in or reset its password:
115/// list the accounts that differ only in case with `SELECT LOWER(TRIM(email)) FROM admin_users
116/// GROUP BY 1 HAVING COUNT(*) > 1`, merge or delete them, then run
117/// `UPDATE admin_users SET email = LOWER(TRIM(email))`. SQLite's `LOWER` folds ASCII letters
118/// only, so lowercase any other capital by hand.
119#[derive(Debug, Default, Clone, Copy)]
120pub struct PasswordAuth;
121
122impl Authenticator for PasswordAuth {
123    type User = AdminUser;
124
125    async fn verify(
126        &self,
127        cx: &Cx,
128        login: &str,
129        password: &str,
130    ) -> topcoat::Result<Option<AdminUser>> {
131        let mut db = crate::db::db(cx)?;
132        let user = AdminUser::filter(AdminUser::fields().email().eq(normalize_email(login)))
133            .first()
134            .exec(&mut db)
135            .await
136            .map_err(infrastructure_failure)?;
137        let hash = user.as_ref().map(|user| user.password_hash.as_str());
138        if !verify_password(password, hash).await {
139            return Ok(None);
140        }
141        Ok(user)
142    }
143
144    async fn find_by_id(&self, cx: &Cx, id: &str) -> topcoat::Result<Option<AdminUser>> {
145        let Ok(id) = Uuid::parse_str(id) else {
146            return Ok(None);
147        };
148        let mut db = crate::db::db(cx)?;
149        let user = AdminUser::filter(AdminUser::fields().id().eq(id))
150            .first()
151            .exec(&mut db)
152            .await
153            .map_err(infrastructure_failure)?;
154        // A deactivated account stops resolving, so its live sessions purge.
155        Ok(user.filter(|user| user.active))
156    }
157}
158
159/// Registers an [`AdminUser`] from a [`SignUp`], refusing a password
160/// [`new_password_errors`](super::new_password_errors) refuses and an email another account
161/// holds. Install it with [`Auth::registration`](super::Auth::registration).
162///
163/// Whether a new account may open the panel is a choice the constructor makes:
164/// [`pending`](Self::pending) stores it inactive, for an administrator to approve by setting
165/// `active`, and the sign-up lands on the login page; [`open`](Self::open) stores it active and
166/// signs it in at once, so any visitor gets the panel's access.
167///
168/// ```rust
169/// use tablo_core::{Auth, PasswordSignUp};
170///
171/// let auth = Auth::password().registration(PasswordSignUp::pending());
172/// # let _ = auth;
173/// ```
174#[derive(Debug, Clone, Copy)]
175pub struct PasswordSignUp {
176    active: bool,
177}
178
179impl PasswordSignUp {
180    /// Stores each new account inactive: it signs in once an administrator approves it.
181    #[must_use]
182    pub fn pending() -> Self {
183        Self { active: false }
184    }
185
186    /// Stores each new account active: any visitor who signs up enters the panel. Open it only
187    /// where any visitor may hold an account.
188    #[must_use]
189    pub fn open() -> Self {
190        Self { active: true }
191    }
192}
193
194impl Registrar for PasswordSignUp {
195    type Input = SignUp;
196    type User = AdminUser;
197
198    async fn validate(
199        &self,
200        _cx: &Cx,
201        sign_up: &SignUp,
202        ex: &mut dyn toasty::Executor,
203    ) -> topcoat::Result<FieldErrors> {
204        let mut errors = sign_up.password_errors();
205        let taken = AdminUser::filter(
206            AdminUser::fields()
207                .email()
208                .eq(normalize_email(&sign_up.email)),
209        )
210        .first()
211        .exec(ex)
212        .await
213        .map_err(infrastructure_failure)?;
214        if taken.is_some() {
215            errors.add("email", email_taken());
216        }
217        Ok(errors)
218    }
219
220    async fn register(
221        &self,
222        _cx: &Cx,
223        sign_up: SignUp,
224        ex: &mut dyn toasty::Executor,
225    ) -> topcoat::Result<AdminUser> {
226        insert_admin(
227            ex,
228            &sign_up.email,
229            &sign_up.password,
230            &sign_up.name,
231            self.active,
232            None,
233        )
234        .await
235    }
236}
237
238/// Edits the signed-in [`AdminUser`]'s name, email and password through a [`Profile`]. Changing
239/// the email or the password asks for the current password, and refuses a new password
240/// [`new_password_errors`](super::new_password_errors) refuses and, once the current password
241/// verifies, an email another account holds. A changed password is a
242/// [`ProfileUpdate::Credentials`]. A new email goes through [`request_email_change`]: on a panel
243/// with [`Auth::email_verification`](super::Auth::email_verification) it waits for its link,
244/// elsewhere it is stored at once, as a credential change. Install it with
245/// [`Auth::profile`](super::Auth::profile).
246impl ProfileEditor for PasswordAuth {
247    type Input = Profile;
248    type User = AdminUser;
249
250    fn fill(&self, _cx: &Cx, admin: &AdminUser) -> Profile {
251        Profile {
252            name: admin.display_name.clone(),
253            email: admin.email.clone(),
254            password: None,
255            password_confirmation: String::new(),
256            current_password: None,
257        }
258    }
259
260    async fn validate(
261        &self,
262        _cx: &Cx,
263        admin: &AdminUser,
264        profile: &Profile,
265        ex: &mut dyn toasty::Executor,
266    ) -> topcoat::Result<FieldErrors> {
267        let mut errors = profile.password_errors();
268        let new_email = normalize_email(&profile.email) != admin.email;
269        if new_email || profile.password.is_some() {
270            match &profile.current_password {
271                None => errors.add(
272                    CURRENT_PASSWORD_FIELD,
273                    crate::text!("tablo-profile-current-password-needed"),
274                ),
275                Some(current) if !verify_password(current, Some(&admin.password_hash)).await => {
276                    errors.add(
277                        CURRENT_PASSWORD_FIELD,
278                        crate::text!("tablo-profile-current-password-wrong"),
279                    );
280                }
281                Some(_) => {}
282            }
283        }
284        // Only the account's owner learns whether another account holds an email: a stolen
285        // session without the password must not probe addresses.
286        if new_email && !errors.contains_key(CURRENT_PASSWORD_FIELD) {
287            let taken = AdminUser::filter(
288                AdminUser::fields()
289                    .email()
290                    .eq(normalize_email(&profile.email)),
291            )
292            .first()
293            .exec(ex)
294            .await
295            .map_err(infrastructure_failure)?;
296            if taken.is_some_and(|other| other.id != admin.id) {
297                errors.add("email", email_taken());
298            }
299        }
300        Ok(errors)
301    }
302
303    async fn save(
304        &self,
305        cx: &Cx,
306        admin: &AdminUser,
307        profile: Profile,
308        ex: &mut dyn toasty::Executor,
309    ) -> topcoat::Result<ProfileUpdate> {
310        let email = normalize_email(&profile.email);
311        let stored = email != admin.email
312            && request_email_change(cx, &mut *ex, admin, &email).await? == EmailChange::Immediate;
313        let credentials = stored || profile.password.is_some();
314        let mut update = AdminUser::filter(AdminUser::fields().id().eq(admin.id))
315            .update()
316            .display_name(profile.name);
317        if stored {
318            update.set_email(email);
319            update.set_email_verified_at(None);
320        }
321        if let Some(password) = &profile.password {
322            update.set_password_hash(hash_password(password).await?);
323        }
324        update.exec(ex).await?;
325        Ok(if credentials {
326            ProfileUpdate::Credentials
327        } else {
328            ProfileUpdate::Details
329        })
330    }
331}
332
333/// Resets an [`AdminUser`]'s password, mailing the link to the email the account stores.
334/// Install it with [`Auth::password_reset`](super::Auth::password_reset).
335impl PasswordResetter for PasswordAuth {
336    type User = AdminUser;
337
338    async fn find_by_email(&self, cx: &Cx, email: &str) -> topcoat::Result<Option<AdminUser>> {
339        let mut db = crate::db::db(cx)?;
340        AdminUser::filter(AdminUser::fields().email().eq(normalize_email(email)))
341            .first()
342            .exec(&mut db)
343            .await
344            .map_err(infrastructure_failure)
345    }
346
347    async fn reset_password(
348        &self,
349        _cx: &Cx,
350        user_id: &str,
351        password: &str,
352        ex: &mut dyn toasty::Executor,
353    ) -> topcoat::Result<()> {
354        let id = Uuid::parse_str(user_id).map_err(|_| topcoat::router::error::not_found())?;
355        AdminUser::filter(AdminUser::fields().id().eq(id))
356            .update()
357            .password_hash(hash_password(password).await?)
358            .exec(ex)
359            .await?;
360        Ok(())
361    }
362}
363
364/// Verifies an [`AdminUser`]'s address, storing the one the link proved. Install it with
365/// [`Auth::email_verification`](super::Auth::email_verification).
366impl EmailVerifier for PasswordAuth {
367    type User = AdminUser;
368
369    fn is_verified(&self, admin: &AdminUser) -> bool {
370        admin.email_verified_at.is_some()
371    }
372
373    async fn store_verified_email(
374        &self,
375        _cx: &Cx,
376        user_id: &str,
377        email: &str,
378        ex: &mut dyn toasty::Executor,
379    ) -> topcoat::Result<()> {
380        let id = Uuid::parse_str(user_id).map_err(|_| topcoat::router::error::not_found())?;
381        AdminUser::filter(AdminUser::fields().id().eq(id))
382            .update()
383            .email(normalize_email(email))
384            .email_verified_at(Some(Timestamp::now()))
385            .exec(ex)
386            .await?;
387        Ok(())
388    }
389}
390
391/// Creates an active [`AdminUser`] that signs in with `email` and `password`, storing only the
392/// password's Argon2id hash: the account a new app seeds to reach its panel. The email is stored
393/// trimmed and lowercased, and verified: whoever seeds the account vouches for the address.
394///
395/// ```rust,no_run
396/// # async fn seed(mut db: toasty::Db) -> topcoat::Result<()> {
397/// tablo_core::auth::create_admin(&mut db, "admin@example.com", "secret", "Admin").await?;
398/// # Ok(())
399/// # }
400/// ```
401///
402/// # Errors
403///
404/// Hashing fails, or the insert does: an email another account holds is refused by its unique
405/// index.
406pub async fn create_admin(
407    ex: &mut dyn toasty::Executor,
408    email: &str,
409    password: &str,
410    display_name: &str,
411) -> topcoat::Result<AdminUser> {
412    insert_admin(
413        ex,
414        email,
415        password,
416        display_name,
417        true,
418        Some(Timestamp::now()),
419    )
420    .await
421}
422
423/// Inserts an [`AdminUser`], `active` or not, verified at `verified_at`.
424async fn insert_admin(
425    ex: &mut dyn toasty::Executor,
426    email: &str,
427    password: &str,
428    display_name: &str,
429    active: bool,
430    verified_at: Option<Timestamp>,
431) -> topcoat::Result<AdminUser> {
432    let admin = toasty::create!(AdminUser {
433        email: normalize_email(email),
434        password_hash: hash_password(password).await?,
435        display_name: display_name.to_string(),
436        active,
437        email_verified_at: verified_at,
438        created_at: Timestamp::now(),
439    })
440    .exec(ex)
441    .await?;
442    Ok(admin)
443}
444
445/// `email` as [`AdminUser`] stores it: trimmed and lowercased.
446fn normalize_email(email: &str) -> String {
447    email.trim().to_lowercase()
448}
449
450/// Hashes a password with Argon2id into a PHC string for storage; never stores
451/// the plaintext. Runs on Tokio's blocking pool, as [`verify_password`] does, one hash per core
452/// at once.
453///
454/// # Errors
455///
456/// Argon2 refuses the password.
457pub async fn hash_password(password: &str) -> topcoat::Result<String> {
458    use argon2::password_hash::PasswordHasher;
459
460    let password = password.to_string();
461    off_executor(move || {
462        argon2::Argon2::default()
463            .hash_password(password.as_bytes())
464            .map(|hash| hash.to_string())
465            .map_err(topcoat::Error::from)
466    })
467    .await
468}
469
470/// Verifies a password against an Argon2id PHC hash, or `None` for an unknown
471/// account; unknown accounts verify against a dummy hash so response times do
472/// not reveal which accounts exist.
473///
474/// Runs on Tokio's blocking pool: a verification takes tens of milliseconds of CPU, which on
475/// an async worker would stall every request that worker serves. One verification or hash per
476/// core runs at once, and the rest wait their turn.
477///
478/// ```rust,no_run
479/// # use tablo_core::auth::verify_password;
480/// # #[derive(Debug, Clone, toasty::Model)]
481/// # struct Staff {
482/// #     #[key] #[auto] id: uuid::Uuid,
483/// #     #[unique]
484/// #     email: String,
485/// #     password_hash: String,
486/// # }
487/// # async fn sign_in(mut db: toasty::Db, login: &str, password: &str)
488/// # -> topcoat::Result<Option<Staff>> {
489/// let staff = Staff::filter_by_email(login).first().exec(&mut db).await?;
490/// if !verify_password(password, staff.as_ref().map(|s| s.password_hash.as_str())).await {
491///     return Ok(None);
492/// }
493/// # Ok(staff)
494/// # }
495/// ```
496#[must_use]
497pub async fn verify_password(password: &str, hash: Option<&str>) -> bool {
498    use argon2::password_hash::{PasswordVerifier, phc::PasswordHash};
499
500    let known = hash.is_some();
501    let password = password.to_string();
502    let hash = hash.unwrap_or(DUMMY_PASSWORD_HASH).to_string();
503    let verified = off_executor(move || match PasswordHash::new(&hash) {
504        Ok(parsed) => argon2::Argon2::default()
505            .verify_password(password.as_bytes(), &parsed)
506            .is_ok(),
507        // No password matches a hash that does not parse: the account cannot sign in until an
508        // operator resets it, so say why.
509        Err(error) => {
510            tracing::error!(error = %error, "a stored password hash does not parse");
511            false
512        }
513    })
514    .await;
515    verified && known
516}
517
518/// How many Argon2 runs the process allows at once: one per core. Each run holds about 19 MiB,
519/// so a flood of sign-ins under distinct logins, which no throttle count refuses, waits here
520/// instead of filling Tokio's blocking pool and the memory with hashes.
521static ARGON2_PERMITS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
522    let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
523    Arc::new(Semaphore::new(cores))
524});
525
526/// Runs `work` on Tokio's blocking pool once one of [`ARGON2_PERMITS`] is free, re-raising its
527/// panic here.
528async fn off_executor<T: Send + 'static>(work: impl FnOnce() -> T + Send + 'static) -> T {
529    permitted(&ARGON2_PERMITS, work).await
530}
531
532/// Runs `work` on Tokio's blocking pool while holding one of `permits`. The work keeps its permit
533/// until it ends, even when the request awaiting it is dropped.
534pub(super) async fn permitted<T: Send + 'static>(
535    permits: &Arc<Semaphore>,
536    work: impl FnOnce() -> T + Send + 'static,
537) -> T {
538    let permit = Arc::clone(permits)
539        .acquire_owned()
540        .await
541        .expect("the Argon2 semaphore is never closed");
542    let work = move || {
543        let value = work();
544        drop(permit);
545        value
546    };
547    match tokio::task::spawn_blocking(work).await {
548        Ok(value) => value,
549        Err(error) => std::panic::resume_unwind(error.into_panic()),
550    }
551}
552
553/// The refusal of an email another account holds.
554fn email_taken() -> crate::Text {
555    crate::text!(
556        "tablo-field-taken",
557        label = crate::text!("tablo-auth-email")
558    )
559}