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 jiff::Timestamp;
5use topcoat::context::Cx;
6use uuid::Uuid;
7
8use super::{Authenticator, PanelUser, Registrar, SignUp, infrastructure_failure};
9use crate::FieldErrors;
10
11/// A dummy Argon2id PHC string verified against when the account does not
12/// exist, so unknown logins pay the same work as known ones (ADR-0013).
13/// Generated with `Argon2::default()` parameters (`m=19456,t=2,p=1`).
14const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$h3oXdPBVwhcgZ1OTO/PuzQ$zLrHLgIkwhqu4ZlLTfSyB8mPuL6mAtaswv/eXJ5ADO8";
15
16/// Shipped credential model with unique email, Argon2id PHC hash, display name,
17/// and active flag; belongs to no tenant.
18/// Its [`ModelColumns`](crate::ModelColumns) leave out the hash, so a resource over the panel's
19/// admins lists them.
20#[derive(Debug, Clone, toasty::Model, crate::ModelColumns)]
21pub struct AdminUser {
22    #[key]
23    #[auto]
24    pub id: Uuid,
25    #[unique]
26    pub email: String,
27    /// Argon2id hash in PHC string format; never stores the plaintext.
28    #[columns(skip)]
29    pub password_hash: String,
30    pub display_name: String,
31    /// `false` denies login and panel access immediately.
32    pub active: bool,
33    pub created_at: Timestamp,
34}
35
36impl PanelUser for AdminUser {
37    fn user_id(&self) -> String {
38        self.id.to_string()
39    }
40
41    fn display_name(&self) -> &str {
42        &self.display_name
43    }
44
45    fn can_access_panel(&self) -> bool {
46        self.active
47    }
48}
49
50/// Shipped default authenticator: Argon2id verification against [`AdminUser`].
51#[derive(Debug, Default, Clone, Copy)]
52pub struct PasswordAuth;
53
54impl Authenticator for PasswordAuth {
55    type User = AdminUser;
56
57    async fn verify(
58        &self,
59        cx: &Cx,
60        login: &str,
61        password: &str,
62    ) -> topcoat::Result<Option<AdminUser>> {
63        let mut db = crate::db::db(cx);
64        let user = AdminUser::filter(AdminUser::fields().email().eq(login.to_string()))
65            .first()
66            .exec(&mut db)
67            .await
68            .map_err(infrastructure_failure)?;
69        let hash = user.as_ref().map(|user| user.password_hash.as_str());
70        if !verify_password(password, hash) {
71            return Ok(None);
72        }
73        Ok(user)
74    }
75
76    async fn find_by_id(&self, cx: &Cx, id: &str) -> topcoat::Result<Option<AdminUser>> {
77        let Ok(id) = Uuid::parse_str(id) else {
78            return Ok(None);
79        };
80        let mut db = crate::db::db(cx);
81        let user = AdminUser::filter(AdminUser::fields().id().eq(id))
82            .first()
83            .exec(&mut db)
84            .await
85            .map_err(infrastructure_failure)?;
86        // A deactivated account stops resolving, so its live sessions purge.
87        Ok(user.filter(|user| user.active))
88    }
89}
90
91/// Registers an active [`AdminUser`] from a [`SignUp`], refusing a password
92/// [`new_password_errors`](super::new_password_errors) refuses and an email another account
93/// holds. Install it with [`Auth::registration`](super::Auth::registration).
94impl Registrar for PasswordAuth {
95    type Input = SignUp;
96    type User = AdminUser;
97
98    async fn validate(
99        &self,
100        _cx: &Cx,
101        sign_up: &SignUp,
102        ex: &mut dyn toasty::Executor,
103    ) -> topcoat::Result<FieldErrors> {
104        let mut errors = sign_up.password_errors();
105        let taken = AdminUser::filter(AdminUser::fields().email().eq(sign_up.email.clone()))
106            .first()
107            .exec(ex)
108            .await
109            .map_err(infrastructure_failure)?;
110        if taken.is_some() {
111            errors.add("email", "Email has already been taken");
112        }
113        Ok(errors)
114    }
115
116    async fn register(
117        &self,
118        _cx: &Cx,
119        sign_up: SignUp,
120        ex: &mut dyn toasty::Executor,
121    ) -> topcoat::Result<AdminUser> {
122        create_admin(ex, &sign_up.email, &sign_up.password, &sign_up.name).await
123    }
124}
125
126/// Creates an active [`AdminUser`] that signs in with `email` and `password`, storing only the
127/// password's Argon2id hash: the account a new app seeds to reach its panel.
128///
129/// ```rust,no_run
130/// # async fn seed(mut db: toasty::Db) -> topcoat::Result<()> {
131/// tablo_core::auth::create_admin(&mut db, "admin@example.com", "secret", "Admin").await?;
132/// # Ok(())
133/// # }
134/// ```
135///
136/// # Errors
137///
138/// Hashing fails, or the insert does: an email another account holds is refused by its unique
139/// index.
140pub async fn create_admin(
141    ex: &mut dyn toasty::Executor,
142    email: &str,
143    password: &str,
144    display_name: &str,
145) -> topcoat::Result<AdminUser> {
146    let admin = toasty::create!(AdminUser {
147        email: email.to_string(),
148        password_hash: hash_password(password)?,
149        display_name: display_name.to_string(),
150        active: true,
151        created_at: Timestamp::now(),
152    })
153    .exec(ex)
154    .await?;
155    Ok(admin)
156}
157
158/// Hashes a password with Argon2id into a PHC string for storage; never stores
159/// the plaintext.
160pub fn hash_password(password: &str) -> topcoat::Result<String> {
161    use argon2::password_hash::PasswordHasher;
162
163    argon2::Argon2::default()
164        .hash_password(password.as_bytes())
165        .map(|hash| hash.to_string())
166        .map_err(topcoat::Error::from)
167}
168
169/// Verifies a password against an Argon2id PHC hash, or `None` for an unknown
170/// account; unknown accounts verify against a dummy hash so response times do
171/// not reveal which accounts exist.
172///
173/// ```rust,no_run
174/// # use tablo_core::auth::verify_password;
175/// # #[derive(Debug, Clone, toasty::Model)]
176/// # struct Staff {
177/// #     #[key] #[auto] id: uuid::Uuid,
178/// #     #[unique]
179/// #     email: String,
180/// #     password_hash: String,
181/// # }
182/// # async fn sign_in(mut db: toasty::Db, login: &str, password: &str)
183/// # -> topcoat::Result<Option<Staff>> {
184/// let staff = Staff::filter_by_email(login).first().exec(&mut db).await?;
185/// if !verify_password(password, staff.as_ref().map(|s| s.password_hash.as_str())) {
186///     return Ok(None);
187/// }
188/// # Ok(staff)
189/// # }
190/// ```
191#[must_use]
192pub fn verify_password(password: &str, hash: Option<&str>) -> bool {
193    use argon2::password_hash::{PasswordVerifier, phc::PasswordHash};
194
195    let Ok(parsed) = PasswordHash::new(hash.unwrap_or(DUMMY_PASSWORD_HASH)) else {
196        return false;
197    };
198    let verified = argon2::Argon2::default()
199        .verify_password(password.as_bytes(), &parsed)
200        .is_ok();
201    verified && hash.is_some()
202}