Skip to main content

acme_proxy/admin/
users.rs

1//! Operator management for the web admin: create, list, re-password, enable,
2//! disable, delete, and the password check the login path runs.
3//!
4//! The operation layer, not a front end: no printing, no HTTP, no terminal.
5//! `src/cli/webadmin.rs` and `src/webadmin/handlers/session.rs` both dispatch
6//! here, which is what keeps the password policy, the duplicate check and the
7//! rehash-on-login identical between them.
8
9use std::io::BufRead;
10use std::sync::Arc;
11
12use tracing::{info, warn};
13
14use crate::admin::ops::DeleteOutcome;
15use crate::admin::password;
16use crate::admin::prompt::confirm;
17use crate::sqlite::admin_session::AdminSession;
18use crate::sqlite::admin_user::AdminUser;
19use crate::sqlite::db::Database;
20
21/// Why creating or re-passwording an operator failed.
22#[derive(Debug, thiserror::Error)]
23pub enum UserError {
24    #[error("database error: {0}")]
25    Database(sqlx::Error),
26    /// The password did not satisfy [`password::check_password_policy`]. The
27    /// string is the operator-facing reason.
28    #[error("{0}")]
29    Policy(String),
30    /// A user by that name already exists. Caught before the INSERT so the
31    /// operator reads a sentence rather than a UNIQUE violation.
32    #[error("an admin user named `{0}` already exists")]
33    DuplicateUsername(String),
34}
35
36impl From<sqlx::Error> for UserError {
37    fn from(error: sqlx::Error) -> Self {
38        Self::Database(error)
39    }
40}
41
42/// The result of checking a username and password.
43///
44/// Every variant but [`AuthOutcome::Authenticated`] must be reported to the
45/// client identically -- one `invalid_credentials`, never "no such user" --
46/// but they are kept apart here so the *log* can say which happened. A caller
47/// that collapses them into the response and not into the log is doing the
48/// right thing with both.
49#[derive(Debug)]
50pub enum AuthOutcome {
51    /// Password verified and the account is usable.
52    Authenticated(Box<AdminUser>),
53    /// No such username. The KDF ran anyway -- see [`authenticate`].
54    UnknownUser,
55    /// The username exists; the password did not match.
56    WrongPassword(Box<AdminUser>),
57    /// The password was right, but the account is `disabled`.
58    Disabled(Box<AdminUser>),
59}
60
61/// Creates an operator.
62///
63/// Order matters: the policy is checked before the duplicate lookup, and the
64/// duplicate lookup before the (expensive) hash, so a rejected request never
65/// pays 600 000 iterations.
66pub async fn create_user(
67    username: &str,
68    plaintext: &str,
69    database: Arc<Database>,
70) -> Result<AdminUser, UserError> {
71    password::check_password_policy(plaintext).map_err(UserError::Policy)?;
72
73    let normalized = username.trim().to_lowercase();
74    if normalized.is_empty() {
75        return Err(UserError::Policy("username must not be empty".to_string()));
76    }
77    if AdminUser::find_by_username(&normalized, &database)
78        .await?
79        .is_some()
80    {
81        return Err(UserError::DuplicateUsername(normalized));
82    }
83
84    let hash = password::hash_password(plaintext);
85    Ok(AdminUser::create(&normalized, &hash, &database).await?)
86}
87
88/// Every operator, oldest first.
89pub async fn list_users(database: Arc<Database>) -> Result<Vec<AdminUser>, sqlx::Error> {
90    AdminUser::list_all(&database).await
91}
92
93/// Replaces an operator's password and **revokes every session they hold**.
94///
95/// The revocation is the point: a password changed because it may have leaked,
96/// that left the leaked session alive, would be a change in name only. Returns
97/// `None` when there is no such user.
98pub async fn set_password(
99    username: &str,
100    plaintext: &str,
101    database: Arc<Database>,
102) -> Result<Option<AdminUser>, UserError> {
103    password::check_password_policy(plaintext).map_err(UserError::Policy)?;
104
105    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
106        return Ok(None);
107    };
108
109    let hash = password::hash_password(plaintext);
110    user.set_password_hash(&hash, &database).await?;
111    AdminSession::delete_for_user(&user.id, &database).await?;
112    Ok(Some(user))
113}
114
115/// Moves an operator between `active` and `disabled`.
116///
117/// Disabling also drops their sessions: leaving them live would mean a
118/// disabled operator kept working until their cookie happened to expire.
119pub async fn set_status(
120    username: &str,
121    status: &str,
122    database: Arc<Database>,
123) -> Result<Option<AdminUser>, sqlx::Error> {
124    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
125        return Ok(None);
126    };
127
128    user.set_status(status, &database).await?;
129    if status == "disabled" {
130        AdminSession::delete_for_user(&user.id, &database).await?;
131    }
132    Ok(Some(user))
133}
134
135/// Revokes every session one operator holds, without touching the account.
136/// `None` when there is no such user.
137pub async fn revoke_sessions(
138    username: &str,
139    database: Arc<Database>,
140) -> Result<Option<u64>, sqlx::Error> {
141    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
142        return Ok(None);
143    };
144    Ok(Some(
145        AdminSession::delete_for_user(&user.id, &database).await?,
146    ))
147}
148
149/// Deletes an operator, cascading to their sessions. `false` when there was no
150/// such user.
151///
152/// The bare form, for a caller with nobody to ask -- see the note above the
153/// deletes in [`crate::admin::ops`].
154pub async fn delete_user(username: &str, database: Arc<Database>) -> Result<bool, sqlx::Error> {
155    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
156        return Ok(false);
157    };
158    AdminUser::delete(&user.id, &database).await
159}
160
161/// [`delete_user`], asking first and naming what goes with it.
162pub async fn confirm_delete_user(
163    username: &str,
164    assume_yes: bool,
165    reader: &mut impl BufRead,
166    database: Arc<Database>,
167) -> Result<DeleteOutcome, sqlx::Error> {
168    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
169        return Ok(DeleteOutcome::NotFound);
170    };
171    let sessions = AdminSession::list_all(Some(&user.id), &database)
172        .await?
173        .len();
174    let prompt = format!(
175        "Delete admin user {} (status: {}, {sessions} session(s) will cascade)?",
176        user.username, user.status
177    );
178    if !confirm(&prompt, assume_yes, reader) {
179        return Ok(DeleteOutcome::Cancelled);
180    }
181    AdminUser::delete(&user.id, &database).await?;
182    Ok(DeleteOutcome::Deleted)
183}
184
185/// Checks a username and password, re-hashing the stored digest if it was
186/// written under parameters this build has moved past.
187///
188/// **The KDF runs even when the username is unknown**, against
189/// [`password::dummy_hash`]. Without that, an unknown user answers in
190/// microseconds and a known one in a quarter-second, and login latency
191/// enumerates the operator table.
192///
193/// Does **not** stamp `last_login_at` or create a session: a login is not
194/// complete until a session exists, which for a user with a second factor is
195/// two requests away. The caller decides when that happened.
196pub async fn authenticate(
197    username: &str,
198    plaintext: &str,
199    database: Arc<Database>,
200) -> Result<AuthOutcome, sqlx::Error> {
201    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
202        // Deliberately discarded: the point is the time it took.
203        let _ = password::verify_password(password::dummy_hash(), plaintext);
204        return Ok(AuthOutcome::UnknownUser);
205    };
206
207    let verified = match password::verify_password(&user.password_hash, plaintext) {
208        Ok(verified) => verified,
209        Err(error) => {
210            // A corrupt row is not a wrong password. Refuse the login, but say
211            // so loudly -- nobody will ever guess this from a 401, and the
212            // account is unusable until `admin user passwd` rewrites it.
213            warn!(event = "admin_password_hash_unreadable",
214                  outcome = "failure",
215                  user_id = %user.id,
216                  username = %user.username,
217                  error = %error,
218                  "stored password hash could not be decoded; \
219                   run `acme-proxy admin user passwd` to rewrite it");
220            return Ok(AuthOutcome::WrongPassword(Box::new(user)));
221        }
222    };
223
224    if !verified {
225        return Ok(AuthOutcome::WrongPassword(Box::new(user)));
226    }
227    if !user.is_active() {
228        return Ok(AuthOutcome::Disabled(Box::new(user)));
229    }
230
231    // The one place a stored hash is ever upgraded. Doing it here, on a
232    // verified password, is the only moment the plaintext is available to
233    // re-derive from.
234    if password::needs_rehash(&user.password_hash) {
235        let rehashed = password::hash_password(plaintext);
236        user.set_password_hash(&rehashed, &database).await?;
237        info!(event = "admin_password_rehashed", outcome = "success", user_id = %user.id);
238    }
239
240    Ok(AuthOutcome::Authenticated(Box::new(user)))
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::sqlite::admin_session::NewSession;
247
248    const GOOD: &str = "a-long-enough-password";
249
250    async fn db() -> Arc<Database> {
251        Arc::new(Database::connect_in_memory().await.unwrap())
252    }
253
254    /// Writes a row whose hash is cheap, so the tests that only care about
255    /// *which branch* `authenticate` takes do not each pay 600 000 iterations.
256    /// The stored form is the real one; only the cost differs.
257    async fn user_with_cheap_password(
258        username: &str,
259        plaintext: &str,
260        database: Arc<Database>,
261    ) -> AdminUser {
262        // 1 iteration, but encoded exactly as production encodes it -- which
263        // also means `needs_rehash` reports true, so any test using this and
264        // then authenticating successfully is exercising the rehash path.
265        let salt = [3u8; 16];
266        let mut digest = [0u8; 32];
267        ring::pbkdf2::derive(
268            ring::pbkdf2::PBKDF2_HMAC_SHA256,
269            std::num::NonZeroU32::new(1).unwrap(),
270            &salt,
271            plaintext.as_bytes(),
272            &mut digest,
273        );
274        let encoded = format!(
275            "pbkdf2-sha256$1${}${}",
276            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, salt),
277            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, digest),
278        );
279        AdminUser::create(username, &encoded, &database)
280            .await
281            .unwrap()
282    }
283
284    #[tokio::test]
285    async fn create_user_normalizes_and_hashes() {
286        let db = db().await;
287        let user = create_user("  Alice ", GOOD, db.clone()).await.unwrap();
288        assert_eq!(user.username, "alice");
289        assert!(user.is_active());
290        // Stored one-way: the plaintext appears nowhere.
291        assert!(!user.password_hash.contains(GOOD));
292        assert_eq!(
293            password::verify_password(&user.password_hash, GOOD),
294            Ok(true)
295        );
296    }
297
298    #[tokio::test]
299    async fn create_user_refuses_a_password_below_the_policy() {
300        let db = db().await;
301        let error = create_user("alice", "short", db.clone()).await.unwrap_err();
302        assert!(matches!(error, UserError::Policy(_)));
303        assert!(error.to_string().contains("at least 12"));
304        // Nothing was written.
305        assert!(list_users(db).await.unwrap().is_empty());
306    }
307
308    #[tokio::test]
309    async fn create_user_refuses_an_empty_username() {
310        let db = db().await;
311        let error = create_user("   ", GOOD, db).await.unwrap_err();
312        assert!(error.to_string().contains("username must not be empty"));
313    }
314
315    #[tokio::test]
316    async fn create_user_refuses_a_duplicate_in_words_not_a_unique_violation() {
317        let db = db().await;
318        create_user("alice", GOOD, db.clone()).await.unwrap();
319        let error = create_user("ALICE", GOOD, db).await.unwrap_err();
320        assert!(matches!(error, UserError::DuplicateUsername(_)));
321        assert_eq!(
322            error.to_string(),
323            "an admin user named `alice` already exists"
324        );
325    }
326
327    #[tokio::test]
328    async fn set_password_revokes_every_session_of_that_user() {
329        let db = db().await;
330        let user = user_with_cheap_password("alice", "old-password", db.clone()).await;
331        AdminSession::create(
332            NewSession {
333                user_id: &user.id,
334                token_hash: "hash-a",
335                csrf_token: "csrf",
336                created_ip: None,
337                user_agent: None,
338            },
339            std::time::Duration::from_secs(60),
340            &db,
341        )
342        .await
343        .unwrap();
344
345        assert!(
346            set_password("alice", GOOD, db.clone())
347                .await
348                .unwrap()
349                .is_some()
350        );
351        assert!(
352            AdminSession::list_all(Some(&user.id), &db)
353                .await
354                .unwrap()
355                .is_empty(),
356            "a password change that left sessions alive would be a change in name only"
357        );
358
359        let reloaded = AdminUser::find_by_username("alice", &db)
360            .await
361            .unwrap()
362            .unwrap();
363        assert_eq!(
364            password::verify_password(&reloaded.password_hash, GOOD),
365            Ok(true)
366        );
367    }
368
369    #[tokio::test]
370    async fn set_password_of_an_unknown_user_is_none_and_checks_the_policy_first() {
371        let db = db().await;
372        assert!(
373            set_password("nobody", GOOD, db.clone())
374                .await
375                .unwrap()
376                .is_none()
377        );
378        // The policy is checked before the lookup, so a bad password for an
379        // unknown user is a policy error rather than a silent `None`.
380        assert!(matches!(
381            set_password("nobody", "short", db).await.unwrap_err(),
382            UserError::Policy(_)
383        ));
384    }
385
386    #[tokio::test]
387    async fn disabling_a_user_drops_their_sessions_but_enabling_does_not() {
388        let db = db().await;
389        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
390        AdminSession::create(
391            NewSession {
392                user_id: &user.id,
393                token_hash: "hash-a",
394                csrf_token: "csrf",
395                created_ip: None,
396                user_agent: None,
397            },
398            std::time::Duration::from_secs(60),
399            &db,
400        )
401        .await
402        .unwrap();
403
404        set_status("alice", "disabled", db.clone()).await.unwrap();
405        assert!(
406            AdminSession::list_all(Some(&user.id), &db)
407                .await
408                .unwrap()
409                .is_empty()
410        );
411
412        // Re-enabling is just a status change; there is nothing to drop.
413        let reenabled = set_status("alice", "active", db.clone())
414            .await
415            .unwrap()
416            .unwrap();
417        assert!(reenabled.is_active());
418        assert!(
419            set_status("nobody", "disabled", db)
420                .await
421                .unwrap()
422                .is_none()
423        );
424    }
425
426    #[tokio::test]
427    async fn revoke_sessions_counts_what_it_removed() {
428        let db = db().await;
429        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
430        for hash in ["a", "b"] {
431            AdminSession::create(
432                NewSession {
433                    user_id: &user.id,
434                    token_hash: hash,
435                    csrf_token: "csrf",
436                    created_ip: None,
437                    user_agent: None,
438                },
439                std::time::Duration::from_secs(60),
440                &db,
441            )
442            .await
443            .unwrap();
444        }
445
446        assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(2));
447        assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(0));
448        assert_eq!(revoke_sessions("nobody", db).await.unwrap(), None);
449    }
450
451    #[tokio::test]
452    async fn delete_user_removes_the_row_and_reports_whether_it_existed() {
453        let db = db().await;
454        user_with_cheap_password("alice", "pw", db.clone()).await;
455        assert!(delete_user("alice", db.clone()).await.unwrap());
456        assert!(!delete_user("alice", db).await.unwrap());
457    }
458
459    #[tokio::test]
460    async fn confirm_delete_user_covers_its_three_outcomes() {
461        let db = db().await;
462        let mut empty: &[u8] = &[];
463        assert_eq!(
464            confirm_delete_user("nobody", true, &mut empty, db.clone())
465                .await
466                .unwrap(),
467            DeleteOutcome::NotFound
468        );
469
470        user_with_cheap_password("alice", "pw", db.clone()).await;
471        let mut no = b"n\n".as_slice();
472        assert_eq!(
473            confirm_delete_user("alice", false, &mut no, db.clone())
474                .await
475                .unwrap(),
476            DeleteOutcome::Cancelled
477        );
478        assert!(
479            AdminUser::find_by_username("alice", &db)
480                .await
481                .unwrap()
482                .is_some()
483        );
484
485        let mut empty: &[u8] = &[];
486        assert_eq!(
487            confirm_delete_user("alice", true, &mut empty, db.clone())
488                .await
489                .unwrap(),
490            DeleteOutcome::Deleted
491        );
492        assert!(
493            AdminUser::find_by_username("alice", &db)
494                .await
495                .unwrap()
496                .is_none()
497        );
498    }
499
500    #[tokio::test]
501    async fn authenticate_accepts_the_right_password() {
502        let db = db().await;
503        user_with_cheap_password("alice", "the-password", db.clone()).await;
504        let outcome = authenticate("alice", "the-password", db).await.unwrap();
505        assert!(matches!(outcome, AuthOutcome::Authenticated(_)));
506    }
507
508    #[tokio::test]
509    async fn authenticate_distinguishes_its_failures_for_the_log() {
510        let db = db().await;
511        user_with_cheap_password("alice", "the-password", db.clone()).await;
512
513        assert!(matches!(
514            authenticate("alice", "wrong", db.clone()).await.unwrap(),
515            AuthOutcome::WrongPassword(_)
516        ));
517        assert!(matches!(
518            authenticate("nobody", "the-password", db.clone())
519                .await
520                .unwrap(),
521            AuthOutcome::UnknownUser
522        ));
523
524        set_status("alice", "disabled", db.clone()).await.unwrap();
525        assert!(matches!(
526            authenticate("alice", "the-password", db).await.unwrap(),
527            AuthOutcome::Disabled(_)
528        ));
529    }
530
531    #[tokio::test]
532    async fn authenticate_is_case_insensitive_in_the_username() {
533        let db = db().await;
534        user_with_cheap_password("alice", "the-password", db.clone()).await;
535        assert!(matches!(
536            authenticate("ALICE", "the-password", db).await.unwrap(),
537            AuthOutcome::Authenticated(_)
538        ));
539    }
540
541    #[tokio::test]
542    async fn a_successful_login_rehashes_a_row_written_under_older_parameters() {
543        let db = db().await;
544        let before = user_with_cheap_password("alice", "the-password", db.clone()).await;
545        assert!(password::needs_rehash(&before.password_hash));
546
547        let outcome = authenticate("alice", "the-password", db.clone())
548            .await
549            .unwrap();
550        let AuthOutcome::Authenticated(user) = outcome else {
551            panic!("expected a successful authentication");
552        };
553        assert!(!password::needs_rehash(&user.password_hash));
554
555        // Persisted, not just updated in memory -- and the new digest still
556        // verifies against the same password.
557        let reloaded = AdminUser::find_by_username("alice", &db)
558            .await
559            .unwrap()
560            .unwrap();
561        assert!(!password::needs_rehash(&reloaded.password_hash));
562        assert_eq!(
563            password::verify_password(&reloaded.password_hash, "the-password"),
564            Ok(true)
565        );
566    }
567
568    #[tokio::test]
569    async fn a_corrupt_stored_hash_refuses_the_login_rather_than_erroring() {
570        let db = db().await;
571        AdminUser::create("alice", "not-a-valid-encoded-hash", &db)
572            .await
573            .unwrap();
574        // Not an Err: a mangled row must not take the whole login endpoint
575        // down, and it must not read as "correct password" either.
576        assert!(matches!(
577            authenticate("alice", "anything", db).await.unwrap(),
578            AuthOutcome::WrongPassword(_)
579        ));
580    }
581
582    #[test]
583    fn every_user_error_renders() {
584        assert!(
585            UserError::Database(sqlx::Error::RowNotFound)
586                .to_string()
587                .starts_with("database error:")
588        );
589        assert_eq!(
590            UserError::Policy("too short".to_string()).to_string(),
591            "too short"
592        );
593        assert_eq!(
594            UserError::DuplicateUsername("bob".to_string()).to_string(),
595            "an admin user named `bob` already exists"
596        );
597    }
598}