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