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

            
7
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
8
use sqlx::Connection;
9
use sqlx::types::Uuid;
10
use std::fmt::Debug;
11

            
12
use super::CmdError;
13
use crate::config::ConfigError;
14
use crate::db::get_connection;
15
use 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)]
21
pub 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)]
29
pub 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`.
42
156
pub async fn account_refusal(user_id: Uuid) -> Result<Option<Refusal>, ConfigError> {
43
156
    let mut conn = get_connection().await.map_err(|err| {
44
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
45
        ConfigError::DB
46
    })?;
47

            
48
156
    let Some(row) = sqlx::query!(
49
        "SELECT quarantined_at, verified FROM users WHERE id = $1",
50
        user_id
51
    )
52
156
    .fetch_optional(&mut *conn)
53
156
    .await?
54
    else {
55
        return Ok(Some(Refusal::Disabled));
56
    };
57

            
58
156
    Ok(match (row.quarantined_at, row.verified) {
59
39
        (_, false) => Some(Refusal::Disabled),
60
        (Some(_), _) => Some(Refusal::Quarantined),
61
117
        _ => None,
62
    })
63
156
}
64

            
65
/// A directory row plus the reason it may not be served, in one read.
66
pub 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.
85
pub 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.
120
39
pub async fn email_taken(email: &str) -> Result<bool, ConfigError> {
121
39
    let mut conn = get_connection().await.map_err(|err| {
122
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
123
        ConfigError::DB
124
    })?;
125

            
126
39
    sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
127
39
        .bind(email.to_ascii_lowercase())
128
39
        .fetch_one(&mut *conn)
129
39
        .await
130
39
        .map_err(Into::into)
131
39
}
132

            
133
/// A row to add to the directory.
134
pub 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.
154
39
pub async fn create_user(new: NewUser<'_>) -> Result<Account, ConfigError> {
155
39
    let mut conn = get_connection().await.map_err(|err| {
156
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
157
        ConfigError::DB
158
    })?;
159

            
160
39
    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
39
        new.email.to_ascii_lowercase(),
167
        new.password_hash,
168
        new.database_name,
169
        new.jwt_public_key
170
    )
171
39
    .fetch_one(&mut *conn)
172
39
    .await?;
173

            
174
39
    Ok(Account {
175
39
        id: row.id,
176
39
        name: row.name,
177
39
        email: row.email,
178
39
        role: row.role,
179
39
        photo: row.photo,
180
39
        verified: row.verified,
181
39
        created_at: row.created_at,
182
39
        updated_at: row.updated_at,
183
39
        refusal: None,
184
39
    })
185
39
}
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.
196
11
pub async fn verify_user_password(
197
11
    email: &str,
198
11
    password: &str,
199
11
) -> Result<Option<VerifiedUser>, ConfigError> {
200
11
    let mut conn = get_connection().await.map_err(|err| {
201
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
202
        ConfigError::DB
203
    })?;
204
11
    let Some(row) = sqlx::query_file!("sql/select/users/by_email.sql", email.to_ascii_lowercase())
205
11
        .fetch_optional(&mut *conn)
206
11
        .await?
207
    else {
208
1
        return Ok(None);
209
    };
210
10
    let Ok(parsed_hash) = PasswordHash::new(&row.password_hash) else {
211
        return Ok(None);
212
    };
213
10
    let is_valid = Argon2::default()
214
10
        .verify_password(password.as_bytes(), &parsed_hash)
215
10
        .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
10
    if !is_valid || !row.verified {
219
4
        return Ok(None);
220
6
    }
221
6
    Ok(Some(VerifiedUser {
222
6
        user_id: row.id,
223
6
        ssh_enabled: row.ssh_enabled,
224
6
        generation: row.session_generation,
225
6
    }))
226
11
}
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.
234
43
pub 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
43
    let hash = Argon2::default()
243
43
        .hash_password(new.as_bytes())
244
43
        .map_err(|_| CmdError::Server(ServerError::Creation))?
245
43
        .to_string();
246

            
247
43
    let mut conn = get_connection().await.map_err(|err| {
248
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
249
        ConfigError::DB
250
    })?;
251
43
    let mut tx = conn.begin().await?;
252
43
    let row = sqlx::query_file!("sql/select/users/by_id_password.sql", user_id)
253
43
        .fetch_optional(&mut *tx)
254
43
        .await?
255
43
        .ok_or_else(|| CmdError::Args(t!("Invalid email or password").to_string()))?;
256

            
257
43
    let verified = PasswordHash::new(&row.password_hash).is_ok_and(|parsed| {
258
43
        Argon2::default()
259
43
            .verify_password(current.as_bytes(), &parsed)
260
43
            .is_ok()
261
43
    });
262
43
    if !verified || !row.verified {
263
3
        return Err(CmdError::Args(t!("Invalid email or password").to_string()));
264
40
    }
265

            
266
40
    sqlx::query_file!("sql/update/users/set_password.sql", user_id, hash)
267
40
        .execute(&mut *tx)
268
40
        .await?;
269
40
    tx.commit().await?;
270
40
    Ok(())
271
43
}
272

            
273
#[cfg(test)]
274
mod 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$\
294
aTltTkFWR2paUHY5Y2pYbVNlR2Rsdz09$QKPc9TsnwNUm7fgdz/JqzHyrYyo4/x2pMvehw5upb8g";
295

            
296
    #[test]
297
1
    fn a_hash_with_foreign_cost_parameters_still_verifies() {
298
1
        let parsed = PasswordHash::new(CLI_GENERATED_HASH).expect("PHC string parses");
299

            
300
1
        assert!(
301
1
            Argon2::default()
302
1
                .verify_password(b"correct horse", &parsed)
303
1
                .is_ok(),
304
            "the promotion runbook depends on a hash made with other parameters verifying"
305
        );
306
1
        assert!(
307
1
            Argon2::default()
308
1
                .verify_password(b"wrong horse", &parsed)
309
1
                .is_err()
310
        );
311
1
    }
312

            
313
9
    async fn setup() {
314
9
        CONTEXT
315
9
            .get_or_init(|| async {
316
                #[cfg(feature = "testlog")]
317
1
                let _ = env_logger::builder()
318
1
                    .is_test(true)
319
1
                    .filter_level(log::LevelFilter::Trace)
320
1
                    .try_init();
321
2
            })
322
9
            .await;
323
9
    }
324

            
325
8
    async fn insert_user_with_password(
326
8
        id: Uuid,
327
8
        email: &str,
328
8
        plaintext: &str,
329
8
    ) -> anyhow::Result<()> {
330
8
        let hash = Argon2::default()
331
8
            .hash_password(plaintext.as_bytes())
332
8
            .unwrap()
333
8
            .to_string();
334
8
        let mut conn = get_connection().await.unwrap();
335
8
        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
8
        .execute(&mut *conn)
348
8
        .await?;
349
8
        Ok(())
350
8
    }
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
1
        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
4
    async fn generation_of(pool: &PgPool, user_id: Uuid) -> anyhow::Result<i32> {
513
        Ok(
514
4
            sqlx::query_scalar("SELECT session_generation FROM users WHERE id = $1")
515
4
                .bind(user_id)
516
4
                .fetch_one(pool)
517
4
                .await?,
518
        )
519
4
    }
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
}