Skip to main content

server/command/
user.rs

1//! User-centric commands that cross both the web and SSH login paths.
2//!
3//! Extracted from `web/src/handler.rs` so the SSH daemon reuses the
4//! exact same password-verification code: same Argon2 parameters,
5//! same "invalid email or password" failure mode.
6
7use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
8use sqlx::Connection;
9use sqlx::types::Uuid;
10use std::fmt::Debug;
11
12use super::CmdError;
13use crate::config::ConfigError;
14use crate::db::get_connection;
15use crate::error::ServerError;
16
17/// What a successful [`verify_user_password`] returns. The daemon gates
18/// the entire password code path on `ssh_enabled = TRUE`; the web
19/// handler ignores that flag since it owns its own login surface.
20#[derive(Debug, Clone)]
21pub struct VerifiedUser {
22    pub user_id: Uuid,
23    pub ssh_enabled: bool,
24    pub generation: i32,
25}
26
27/// Why a session may not proceed, when it may not.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Refusal {
30    /// Withheld by the fleet migrator. Transient.
31    Quarantined,
32    Disabled,
33}
34
35/// Whether this user may currently be served, and if not, why.
36///
37/// Read from the global directory: a database that cannot be reached cannot
38/// record its own failure, so neither flag lives in the tenant.
39///
40/// # Errors
41/// Only on infrastructure failure. An unknown user is refused as `Disabled`.
42pub async fn account_refusal(user_id: Uuid) -> Result<Option<Refusal>, ConfigError> {
43    let mut conn = get_connection().await.map_err(|err| {
44        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
45        ConfigError::DB
46    })?;
47
48    let Some(row) = sqlx::query!(
49        "SELECT quarantined_at, verified FROM users WHERE id = $1",
50        user_id
51    )
52    .fetch_optional(&mut *conn)
53    .await?
54    else {
55        return Ok(Some(Refusal::Disabled));
56    };
57
58    Ok(match (row.quarantined_at, row.verified) {
59        (_, false) => Some(Refusal::Disabled),
60        (Some(_), _) => Some(Refusal::Quarantined),
61        _ => None,
62    })
63}
64
65/// A directory row plus the reason it may not be served, in one read.
66pub struct Account {
67    pub id: Uuid,
68    pub name: String,
69    pub email: String,
70    pub role: String,
71    pub photo: String,
72    pub verified: bool,
73    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
74    pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
75    pub refusal: Option<Refusal>,
76}
77
78/// Fetches an account and its serving status.
79///
80/// The one read every authenticated request already performs, kept in this
81/// crate: `web` must not open its own connection to the directory.
82///
83/// # Errors
84/// [`ConfigError::DB`] when the directory is unreachable.
85pub async fn fetch_account(user_id: Uuid) -> Result<Option<Account>, ConfigError> {
86    let mut conn = get_connection().await.map_err(|err| {
87        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
88        ConfigError::DB
89    })?;
90
91    let row = sqlx::query!(
92        "SELECT id, user_name as name, email, user_role as role, photo, verified, \
93         quarantined_at, created_at, updated_at FROM users WHERE id = $1",
94        user_id
95    )
96    .fetch_optional(&mut *conn)
97    .await?;
98
99    Ok(row.map(|row| Account {
100        refusal: match (row.quarantined_at, row.verified) {
101            (_, false) => Some(Refusal::Disabled),
102            (Some(_), _) => Some(Refusal::Quarantined),
103            _ => None,
104        },
105        id: row.id,
106        name: row.name,
107        email: row.email,
108        role: row.role,
109        photo: row.photo,
110        verified: row.verified,
111        created_at: row.created_at,
112        updated_at: row.updated_at,
113    }))
114}
115
116/// Whether an email is already registered.
117///
118/// # Errors
119/// [`ConfigError::DB`] when the directory is unreachable.
120pub async fn email_taken(email: &str) -> Result<bool, ConfigError> {
121    let mut conn = get_connection().await.map_err(|err| {
122        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
123        ConfigError::DB
124    })?;
125
126    sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
127        .bind(email.to_ascii_lowercase())
128        .fetch_one(&mut *conn)
129        .await
130        .map_err(Into::into)
131}
132
133/// A row to add to the directory.
134pub struct NewUser<'a> {
135    pub id: Uuid,
136    pub name: &'a str,
137    pub email: &'a str,
138    pub password_hash: &'a str,
139    pub database_name: &'a str,
140    pub jwt_public_key: &'a str,
141}
142
143/// Inserts a directory row for a freshly provisioned tenant.
144///
145/// `verified` is set here rather than by changing `0003_users.sql`'s default:
146/// that migration is applied in the global database and in live tenants, so
147/// editing it would change its checksum and fail every boot.
148///
149/// # Errors
150/// [`ConfigError::DB`] when the directory is unreachable, and the underlying
151/// sqlx error when the insert itself fails — a concurrent registration winning
152/// the `UNIQUE (email)` race reaches the caller as an error so it can drop the
153/// database it already provisioned.
154pub async fn create_user(new: NewUser<'_>) -> Result<Account, ConfigError> {
155    let mut conn = get_connection().await.map_err(|err| {
156        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
157        ConfigError::DB
158    })?;
159
160    let row = sqlx::query!(
161        "INSERT INTO users (id,user_name,email,user_password,database_name,jwt_public_key,verified) \
162         VALUES ($1, $2, $3, $4, $5, $6, TRUE) \
163         RETURNING id, user_name as name, email, user_role as role, photo, verified, created_at, updated_at",
164        new.id,
165        new.name,
166        new.email.to_ascii_lowercase(),
167        new.password_hash,
168        new.database_name,
169        new.jwt_public_key
170    )
171    .fetch_one(&mut *conn)
172    .await?;
173
174    Ok(Account {
175        id: row.id,
176        name: row.name,
177        email: row.email,
178        role: row.role,
179        photo: row.photo,
180        verified: row.verified,
181        created_at: row.created_at,
182        updated_at: row.updated_at,
183        refusal: None,
184    })
185}
186
187/// Shared verification helper returning the richer `VerifiedUser`
188/// shape. The SSH daemon uses this directly so it can gate the
189/// password path on `ssh_enabled`.
190///
191/// # Errors
192///
193/// Returns `Err` only on infrastructure failure (DB unavailable).
194/// Mismatched credentials resolve to `Ok(None)` — the single failure
195/// mode the caller should pattern-match on.
196pub async fn verify_user_password(
197    email: &str,
198    password: &str,
199) -> Result<Option<VerifiedUser>, ConfigError> {
200    let mut conn = get_connection().await.map_err(|err| {
201        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
202        ConfigError::DB
203    })?;
204    let Some(row) = sqlx::query_file!("sql/select/users/by_email.sql", email.to_ascii_lowercase())
205        .fetch_optional(&mut *conn)
206        .await?
207    else {
208        return Ok(None);
209    };
210    let Ok(parsed_hash) = PasswordHash::new(&row.password_hash) else {
211        return Ok(None);
212    };
213    let is_valid = Argon2::default()
214        .verify_password(password.as_bytes(), &parsed_hash)
215        .is_ok();
216    // After the hash, not before: an early return would make a disabled
217    // account measurably faster to probe than a wrong password.
218    if !is_valid || !row.verified {
219        return Ok(None);
220    }
221    Ok(Some(VerifiedUser {
222        user_id: row.id,
223        ssh_enabled: row.ssh_enabled,
224        generation: row.session_generation,
225    }))
226}
227
228/// Replaces the password and revokes every outstanding session, in one
229/// statement.
230///
231/// # Errors
232/// `CmdError::Args` when the current password does not verify; otherwise only
233/// on infrastructure failure.
234pub async fn change_password(user_id: Uuid, current: &str, new: &str) -> Result<(), CmdError> {
235    // Hashed before taking a connection: Argon2 is deliberately slow and the
236    // hash does not depend on the row, so it has no business holding one of
237    // the admin pool's five connections.
238    //
239    // Not `Args`: failing to produce a hash is an infrastructure fault, and the
240    // handler turns `Args` into "invalid password", which would send the user
241    // off retyping a password that was right.
242    let hash = Argon2::default()
243        .hash_password(new.as_bytes())
244        .map_err(|_| CmdError::Server(ServerError::Creation))?
245        .to_string();
246
247    let mut conn = get_connection().await.map_err(|err| {
248        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
249        ConfigError::DB
250    })?;
251    let mut tx = conn.begin().await?;
252    let row = sqlx::query_file!("sql/select/users/by_id_password.sql", user_id)
253        .fetch_optional(&mut *tx)
254        .await?
255        .ok_or_else(|| CmdError::Args(t!("Invalid email or password").to_string()))?;
256
257    let verified = PasswordHash::new(&row.password_hash).is_ok_and(|parsed| {
258        Argon2::default()
259            .verify_password(current.as_bytes(), &parsed)
260            .is_ok()
261    });
262    if !verified || !row.verified {
263        return Err(CmdError::Args(t!("Invalid email or password").to_string()));
264    }
265
266    sqlx::query_file!("sql/update/users/set_password.sql", user_id, hash)
267        .execute(&mut *tx)
268        .await?;
269    tx.commit().await?;
270    Ok(())
271}
272
273#[cfg(test)]
274mod command_tests {
275    use super::*;
276    use crate::db::DB_POOL;
277    use argon2::{Argon2, PasswordHasher};
278    use sqlx::PgPool;
279    use supp_macro::local_db_sqlx_test;
280    use tokio::sync::OnceCell;
281
282    static CONTEXT: OnceCell<()> = OnceCell::const_new();
283
284    /// A hash the `argon2` CLI produced, with cost parameters deliberately
285    /// unlike `Argon2::default()`.
286    ///
287    /// The break-glass admin is promoted by hand with a hash generated
288    /// out-of-band (doc/ops/0006, Phase 4a), and that runbook tells the operator
289    /// the cost settings need not match the seeded row. This is what makes that
290    /// true: verification reads the parameters out of the PHC string. If it ever
291    /// stops being true, the operator finds out during an outage, locked out of
292    /// the account they just promoted.
293    const CLI_GENERATED_HASH: &str = "$argon2id$v=19$m=4096,t=3,p=1$\
294aTltTkFWR2paUHY5Y2pYbVNlR2Rsdz09$QKPc9TsnwNUm7fgdz/JqzHyrYyo4/x2pMvehw5upb8g";
295
296    #[test]
297    fn a_hash_with_foreign_cost_parameters_still_verifies() {
298        let parsed = PasswordHash::new(CLI_GENERATED_HASH).expect("PHC string parses");
299
300        assert!(
301            Argon2::default()
302                .verify_password(b"correct horse", &parsed)
303                .is_ok(),
304            "the promotion runbook depends on a hash made with other parameters verifying"
305        );
306        assert!(
307            Argon2::default()
308                .verify_password(b"wrong horse", &parsed)
309                .is_err()
310        );
311    }
312
313    async fn setup() {
314        CONTEXT
315            .get_or_init(|| async {
316                #[cfg(feature = "testlog")]
317                let _ = env_logger::builder()
318                    .is_test(true)
319                    .filter_level(log::LevelFilter::Trace)
320                    .try_init();
321            })
322            .await;
323    }
324
325    async fn insert_user_with_password(
326        id: Uuid,
327        email: &str,
328        plaintext: &str,
329    ) -> anyhow::Result<()> {
330        let hash = Argon2::default()
331            .hash_password(plaintext.as_bytes())
332            .unwrap()
333            .to_string();
334        let mut conn = get_connection().await.unwrap();
335        sqlx::query!(
336            "INSERT INTO users (
337                id, user_name, email, photo, verified,
338                user_password, user_role, database_name, created_at
339             ) VALUES (
340                $1, 'Test', $2, 'default.png', TRUE,
341                $3, 'user', 'db', NOW()
342             )",
343            id,
344            email,
345            hash,
346        )
347        .execute(&mut *conn)
348        .await?;
349        Ok(())
350    }
351
352    #[local_db_sqlx_test]
353    async fn verify_returns_user_on_correct_password(pool: PgPool) -> anyhow::Result<()> {
354        let uid = Uuid::new_v4();
355        insert_user_with_password(uid, "good@example.com", "hunter2").await?;
356        let v = verify_user_password("good@example.com", "hunter2")
357            .await?
358            .expect("match");
359        assert_eq!(v.user_id, uid);
360        assert!(!v.ssh_enabled);
361    }
362
363    #[local_db_sqlx_test]
364    async fn verify_returns_none_on_wrong_password(pool: PgPool) -> anyhow::Result<()> {
365        let uid = Uuid::new_v4();
366        insert_user_with_password(uid, "wrong@example.com", "hunter2").await?;
367        let v = verify_user_password("wrong@example.com", "nope").await?;
368        assert!(v.is_none());
369    }
370
371    #[local_db_sqlx_test]
372    async fn verify_returns_none_for_unknown_email(pool: PgPool) -> anyhow::Result<()> {
373        let _ = pool;
374        let v = verify_user_password("nobody@example.com", "whatever").await?;
375        assert!(v.is_none());
376    }
377
378    /// Disable is `verified = FALSE`, and it must close the password path on
379    /// both surfaces at once: this function is shared by web login and the SSH
380    /// daemon's password auth.
381    #[local_db_sqlx_test]
382    async fn verify_returns_none_for_a_disabled_user(pool: PgPool) -> anyhow::Result<()> {
383        let uid = Uuid::new_v4();
384        insert_user_with_password(uid, "off@example.com", "hunter2").await?;
385        assert!(
386            verify_user_password("off@example.com", "hunter2")
387                .await?
388                .is_some()
389        );
390
391        sqlx::query("UPDATE users SET verified = FALSE WHERE id = $1")
392            .bind(uid)
393            .execute(&pool)
394            .await?;
395
396        assert!(
397            verify_user_password("off@example.com", "hunter2")
398                .await?
399                .is_none()
400        );
401    }
402
403    /// A password change must replace the hash and bump the generation in one
404    /// go: every token carrying the old value stops verifying the moment it
405    /// commits.
406    #[local_db_sqlx_test]
407    async fn change_password_bumps_the_generation(pool: PgPool) -> anyhow::Result<()> {
408        let uid = Uuid::new_v4();
409        insert_user_with_password(uid, "bump@example.com", "hunter2").await?;
410        assert_eq!(generation_of(&pool, uid).await?, 0);
411
412        change_password(uid, "hunter2", "hunter3").await?;
413
414        assert_eq!(generation_of(&pool, uid).await?, 1);
415        assert!(
416            verify_user_password("bump@example.com", "hunter3")
417                .await?
418                .is_some(),
419            "the new password must work"
420        );
421        assert!(
422            verify_user_password("bump@example.com", "hunter2")
423                .await?
424                .is_none(),
425            "the old password must not"
426        );
427    }
428
429    /// A wrong current password must change nothing — in particular it must not
430    /// bump the generation, which would log the user out of every session.
431    #[local_db_sqlx_test]
432    async fn change_password_refuses_a_wrong_current(pool: PgPool) -> anyhow::Result<()> {
433        let uid = Uuid::new_v4();
434        insert_user_with_password(uid, "wrongcur@example.com", "hunter2").await?;
435
436        assert!(change_password(uid, "not-it", "hunter3").await.is_err());
437
438        assert_eq!(generation_of(&pool, uid).await?, 0);
439        assert!(
440            verify_user_password("wrongcur@example.com", "hunter2")
441                .await?
442                .is_some(),
443            "the original password must still work"
444        );
445    }
446
447    /// A disabled account must not be able to change its own password: doing so
448    /// would let it mint a fresh session generation.
449    #[local_db_sqlx_test]
450    async fn change_password_refuses_a_disabled_user(pool: PgPool) -> anyhow::Result<()> {
451        let uid = Uuid::new_v4();
452        insert_user_with_password(uid, "offpw@example.com", "hunter2").await?;
453        sqlx::query("UPDATE users SET verified = FALSE WHERE id = $1")
454            .bind(uid)
455            .execute(&pool)
456            .await?;
457
458        assert!(change_password(uid, "hunter2", "hunter3").await.is_err());
459        assert_eq!(generation_of(&pool, uid).await?, 0);
460    }
461
462    /// The current-password check authorises the write, so it must be part of
463    /// it. Two changes racing on the same old password would otherwise both
464    /// verify, and the loser's new password would be silently overwritten by a
465    /// caller that never knew it.
466    #[local_db_sqlx_test]
467    async fn change_password_serialises_a_concurrent_change(pool: PgPool) -> anyhow::Result<()> {
468        let uid = Uuid::new_v4();
469        insert_user_with_password(uid, "race@example.com", "hunter2").await?;
470
471        // Hold the row, so the command blocks on the lock rather than racing it.
472        let mut blocker = pool.begin().await?;
473        sqlx::query("SELECT user_password FROM users WHERE id = $1 FOR UPDATE")
474            .bind(uid)
475            .fetch_one(&mut *blocker)
476            .await?;
477
478        let racing = tokio::spawn(async move { change_password(uid, "hunter2", "loser").await });
479
480        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
481        let winner = Argon2::default()
482            .hash_password(b"winner")
483            .expect("hash")
484            .to_string();
485        sqlx::query(
486            "UPDATE users SET user_password = $2, session_generation = session_generation + 1 \
487             WHERE id = $1",
488        )
489        .bind(uid)
490        .bind(&winner)
491        .execute(&mut *blocker)
492        .await?;
493        blocker.commit().await?;
494
495        assert!(
496            racing.await?.is_err(),
497            "the old password must not authorise a write once another change has committed"
498        );
499        assert!(
500            verify_user_password("race@example.com", "winner")
501                .await?
502                .is_some(),
503            "the committed change must survive"
504        );
505        assert!(
506            verify_user_password("race@example.com", "loser")
507                .await?
508                .is_none()
509        );
510    }
511
512    async fn generation_of(pool: &PgPool, user_id: Uuid) -> anyhow::Result<i32> {
513        Ok(
514            sqlx::query_scalar("SELECT session_generation FROM users WHERE id = $1")
515                .bind(user_id)
516                .fetch_one(pool)
517                .await?,
518        )
519    }
520
521    #[local_db_sqlx_test]
522    async fn verify_is_case_insensitive_on_email(pool: PgPool) -> anyhow::Result<()> {
523        let uid = Uuid::new_v4();
524        insert_user_with_password(uid, "mixed@example.com", "pw").await?;
525        let v = verify_user_password("MIXED@EXAMPLE.COM", "pw")
526            .await?
527            .expect("match");
528        assert_eq!(v.user_id, uid);
529    }
530}