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, infrastructure_failure};
9
10/// A dummy Argon2id PHC string verified against when the account does not
11/// exist, so unknown logins pay the same work as known ones (ADR-0013).
12/// Generated with `Argon2::default()` parameters (`m=19456,t=2,p=1`).
13const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$h3oXdPBVwhcgZ1OTO/PuzQ$zLrHLgIkwhqu4ZlLTfSyB8mPuL6mAtaswv/eXJ5ADO8";
14
15/// Shipped credential model with unique email, Argon2id PHC hash, display name,
16/// and active flag; belongs to no tenant.
17#[derive(Debug, Clone, toasty::Model)]
18pub struct AdminUser {
19    #[key]
20    #[auto]
21    pub id: Uuid,
22    #[unique]
23    pub email: String,
24    /// Argon2id hash in PHC string format; never stores the plaintext.
25    pub password_hash: String,
26    pub display_name: String,
27    /// `false` denies login and panel access immediately.
28    pub active: bool,
29    pub created_at: Timestamp,
30}
31
32impl PanelUser for AdminUser {
33    fn user_id(&self) -> String {
34        self.id.to_string()
35    }
36
37    fn display_name(&self) -> &str {
38        &self.display_name
39    }
40
41    fn can_access_panel(&self) -> bool {
42        self.active
43    }
44}
45
46/// Shipped default authenticator: Argon2id verification against [`AdminUser`].
47#[derive(Debug, Default, Clone, Copy)]
48pub struct PasswordAuth;
49
50impl Authenticator for PasswordAuth {
51    type User = AdminUser;
52
53    async fn verify(
54        &self,
55        cx: &Cx,
56        login: &str,
57        password: &str,
58    ) -> topcoat::Result<Option<AdminUser>> {
59        let mut db = crate::db::db(cx);
60        let user = AdminUser::filter(AdminUser::fields().email().eq(login.to_string()))
61            .first()
62            .exec(&mut db)
63            .await
64            .map_err(infrastructure_failure)?;
65        let hash = user.as_ref().map(|user| user.password_hash.as_str());
66        if !verify_password(password, hash) {
67            return Ok(None);
68        }
69        Ok(user)
70    }
71
72    async fn find_by_id(&self, cx: &Cx, id: &str) -> topcoat::Result<Option<AdminUser>> {
73        let Ok(id) = Uuid::parse_str(id) else {
74            return Ok(None);
75        };
76        let mut db = crate::db::db(cx);
77        let user = AdminUser::filter(AdminUser::fields().id().eq(id))
78            .first()
79            .exec(&mut db)
80            .await
81            .map_err(infrastructure_failure)?;
82        // A deactivated account stops resolving, so its live sessions purge.
83        Ok(user.filter(|user| user.active))
84    }
85}
86
87/// Hashes a password with Argon2id into a PHC string for storage; never stores
88/// the plaintext.
89pub fn hash_password(password: &str) -> topcoat::Result<String> {
90    use argon2::password_hash::PasswordHasher;
91
92    argon2::Argon2::default()
93        .hash_password(password.as_bytes())
94        .map(|hash| hash.to_string())
95        .map_err(topcoat::Error::from)
96}
97
98/// Verifies a password against an Argon2id PHC hash, or `None` for an unknown
99/// account; unknown accounts verify against a dummy hash so response times do
100/// not reveal which accounts exist.
101///
102/// ```text
103/// let staff = Staff::filter_by_email(login).first().exec(&mut db).await?;
104/// if !verify_password(password, staff.as_ref().map(|s| s.password_hash.as_str())) {
105///     return Ok(None);
106/// }
107/// ```
108#[must_use]
109pub fn verify_password(password: &str, hash: Option<&str>) -> bool {
110    use argon2::password_hash::{PasswordVerifier, phc::PasswordHash};
111
112    let Ok(parsed) = PasswordHash::new(hash.unwrap_or(DUMMY_PASSWORD_HASH)) else {
113        return false;
114    };
115    let verified = argon2::Argon2::default()
116        .verify_password(password.as_bytes(), &parsed)
117        .is_ok();
118    verified && hash.is_some()
119}