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::{self, PasswordContext};
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    context: &PasswordContext,
70    database: Arc<Database>,
71) -> Result<AdminUser, UserError> {
72    password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
73
74    let normalized = username.trim().to_lowercase();
75    if normalized.is_empty() {
76        return Err(UserError::Policy("username must not be empty".to_string()));
77    }
78    if AdminUser::find_by_username(&normalized, &database)
79        .await?
80        .is_some()
81    {
82        return Err(UserError::DuplicateUsername(normalized));
83    }
84
85    let hash = password::hash_password(plaintext);
86    Ok(AdminUser::create(&normalized, &hash, &database).await?)
87}
88
89/// Every operator, oldest first.
90pub async fn list_users(database: Arc<Database>) -> Result<Vec<AdminUser>, sqlx::Error> {
91    AdminUser::list_all(&database).await
92}
93
94/// Replaces an operator's password and **revokes every session they hold**.
95///
96/// The revocation is the point: a password changed because it may have leaked,
97/// that left the leaked session alive, would be a change in name only. Returns
98/// `None` when there is no such user.
99pub async fn set_password(
100    username: &str,
101    plaintext: &str,
102    context: &PasswordContext,
103    database: Arc<Database>,
104) -> Result<Option<AdminUser>, UserError> {
105    password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
106
107    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
108        return Ok(None);
109    };
110
111    let hash = password::hash_password(plaintext);
112    user.set_password_hash(&hash, &database).await?;
113    AdminSession::delete_for_user(&user.id, &database).await?;
114    Ok(Some(user))
115}
116
117/// Moves an operator between `active` and `disabled`.
118///
119/// Disabling also drops their sessions: leaving them live would mean a
120/// disabled operator kept working until their cookie happened to expire.
121pub async fn set_status(
122    username: &str,
123    status: &str,
124    database: Arc<Database>,
125) -> Result<Option<AdminUser>, sqlx::Error> {
126    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
127        return Ok(None);
128    };
129
130    user.set_status(status, &database).await?;
131    if status == "disabled" {
132        AdminSession::delete_for_user(&user.id, &database).await?;
133    }
134    Ok(Some(user))
135}
136
137/// Revokes every session one operator holds, without touching the account.
138/// `None` when there is no such user.
139pub async fn revoke_sessions(
140    username: &str,
141    database: Arc<Database>,
142) -> Result<Option<u64>, sqlx::Error> {
143    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
144        return Ok(None);
145    };
146    Ok(Some(
147        AdminSession::delete_for_user(&user.id, &database).await?,
148    ))
149}
150
151/// Deletes an operator, cascading to their sessions. `false` when there was no
152/// such user.
153///
154/// The bare form, for a caller with nobody to ask -- see the note above the
155/// deletes in [`crate::admin::ops`].
156pub async fn delete_user(username: &str, database: Arc<Database>) -> Result<bool, sqlx::Error> {
157    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
158        return Ok(false);
159    };
160    AdminUser::delete(&user.id, &database).await
161}
162
163/// [`delete_user`], asking first and naming what goes with it.
164pub async fn confirm_delete_user(
165    username: &str,
166    assume_yes: bool,
167    reader: &mut impl BufRead,
168    database: Arc<Database>,
169) -> Result<DeleteOutcome, sqlx::Error> {
170    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
171        return Ok(DeleteOutcome::NotFound);
172    };
173    let sessions = AdminSession::list_all(Some(&user.id), &database)
174        .await?
175        .len();
176    let prompt = format!(
177        "Delete admin user {} (status: {}, {sessions} session(s) will cascade)?",
178        user.username, user.status
179    );
180    if !confirm(&prompt, assume_yes, reader) {
181        return Ok(DeleteOutcome::Cancelled);
182    }
183    AdminUser::delete(&user.id, &database).await?;
184    Ok(DeleteOutcome::Deleted)
185}
186
187/// Checks a username and password, re-hashing the stored digest if it was
188/// written under parameters this build has moved past.
189///
190/// **The KDF runs even when the username is unknown**, against
191/// [`password::dummy_hash`]. Without that, an unknown user answers in
192/// microseconds and a known one in a quarter-second, and login latency
193/// enumerates the operator table.
194///
195/// Does **not** stamp `last_login_at` or create a session: a login is not
196/// complete until a session exists, which for a user with a second factor is
197/// two requests away. The caller decides when that happened.
198pub async fn authenticate(
199    username: &str,
200    plaintext: &str,
201    database: Arc<Database>,
202) -> Result<AuthOutcome, sqlx::Error> {
203    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
204        // Deliberately discarded: the point is the time it took.
205        let _ = password::verify_password(password::dummy_hash(), plaintext);
206        return Ok(AuthOutcome::UnknownUser);
207    };
208
209    let verified = match password::verify_password(&user.password_hash, plaintext) {
210        Ok(verified) => verified,
211        Err(error) => {
212            // A corrupt row is not a wrong password. Refuse the login, but say
213            // so loudly -- nobody will ever guess this from a 401, and the
214            // account is unusable until `admin user passwd` rewrites it.
215            warn!(event = "admin_password_hash_unreadable",
216                  outcome = "failure",
217                  user_id = %user.id,
218                  username = %user.username,
219                  error = %error,
220                  "stored password hash could not be decoded; \
221                   run `acme-proxy admin user passwd` to rewrite it");
222            return Ok(AuthOutcome::WrongPassword(Box::new(user)));
223        }
224    };
225
226    if !verified {
227        return Ok(AuthOutcome::WrongPassword(Box::new(user)));
228    }
229    if !user.is_active() {
230        return Ok(AuthOutcome::Disabled(Box::new(user)));
231    }
232
233    // The one place a stored hash is ever upgraded. Doing it here, on a
234    // verified password, is the only moment the plaintext is available to
235    // re-derive from.
236    if password::needs_rehash(&user.password_hash) {
237        let rehashed = password::hash_password(plaintext);
238        user.set_password_hash(&rehashed, &database).await?;
239        info!(event = "admin_password_rehashed", outcome = "success", user_id = %user.id);
240    }
241
242    Ok(AuthOutcome::Authenticated(Box::new(user)))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::sqlite::admin_session::NewSession;
249
250    const GOOD: &str = "a-long-enough-password";
251
252    async fn db() -> Arc<Database> {
253        Arc::new(Database::connect_in_memory().await.unwrap())
254    }
255
256    /// Writes a row whose hash is cheap, so the tests that only care about
257    /// *which branch* `authenticate` takes do not each pay 600 000 iterations.
258    /// The stored form is the real one; only the cost differs.
259    async fn user_with_cheap_password(
260        username: &str,
261        plaintext: &str,
262        database: Arc<Database>,
263    ) -> AdminUser {
264        // 1 iteration, but encoded exactly as production encodes it -- which
265        // also means `needs_rehash` reports true, so any test using this and
266        // then authenticating successfully is exercising the rehash path.
267        let salt = [3u8; 16];
268        let mut digest = [0u8; 32];
269        ring::pbkdf2::derive(
270            ring::pbkdf2::PBKDF2_HMAC_SHA256,
271            std::num::NonZeroU32::new(1).unwrap(),
272            &salt,
273            plaintext.as_bytes(),
274            &mut digest,
275        );
276        let encoded = format!(
277            "pbkdf2-sha256$1${}${}",
278            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, salt),
279            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, digest),
280        );
281        AdminUser::create(username, &encoded, &database)
282            .await
283            .unwrap()
284    }
285
286    #[tokio::test]
287    async fn create_user_normalizes_and_hashes() {
288        let db = db().await;
289        let user = create_user("  Alice ", GOOD, &PasswordContext::empty(), db.clone())
290            .await
291            .unwrap();
292        assert_eq!(user.username, "alice");
293        assert!(user.is_active());
294        // Stored one-way: the plaintext appears nowhere.
295        assert!(!user.password_hash.contains(GOOD));
296        assert_eq!(
297            password::verify_password(&user.password_hash, GOOD),
298            Ok(true)
299        );
300    }
301
302    #[tokio::test]
303    async fn create_user_refuses_a_password_below_the_policy() {
304        let db = db().await;
305        let error = create_user("alice", "short", &PasswordContext::empty(), db.clone())
306            .await
307            .unwrap_err();
308        assert!(matches!(error, UserError::Policy(_)));
309        assert!(error.to_string().contains("at least 12"));
310        // Nothing was written.
311        assert!(list_users(db).await.unwrap().is_empty());
312    }
313
314    /// The other two policy rules reach this layer through the same
315    /// `UserError::Policy`, and leave the table as untouched as the length
316    /// rule does.
317    #[tokio::test]
318    async fn create_user_refuses_a_common_password_and_a_deployment_word() {
319        let db = db().await;
320
321        let error = create_user(
322            "alice",
323            "passwordpassword",
324            &PasswordContext::empty(),
325            db.clone(),
326        )
327        .await
328        .unwrap_err();
329        assert!(matches!(error, UserError::Policy(_)));
330        assert!(error.to_string().contains("commonly used"));
331
332        let mut config = crate::config::Config::default();
333        config.server.base_url = "https://ca.example.com".to_string();
334        let context = PasswordContext::from_config(&config, "alice");
335        let error = create_user("alice", "acmeproxy2026!!", &context, db.clone())
336            .await
337            .unwrap_err();
338        assert!(matches!(error, UserError::Policy(_)));
339        assert!(error.to_string().contains("names this deployment"));
340
341        // Neither attempt wrote a row.
342        assert!(list_users(db).await.unwrap().is_empty());
343    }
344
345    /// `set_password` checks the policy before the user lookup, so all three
346    /// rules answer the same way for a name that does not exist.
347    #[tokio::test]
348    async fn set_password_refuses_a_common_password_before_looking_the_user_up() {
349        let db = db().await;
350        let error = set_password(
351            "nobody",
352            "passwordpassword",
353            &PasswordContext::empty(),
354            db.clone(),
355        )
356        .await
357        .unwrap_err();
358        assert!(error.to_string().contains("commonly used"), "got: {error}");
359    }
360
361    #[tokio::test]
362    async fn create_user_refuses_an_empty_username() {
363        let db = db().await;
364        let error = create_user("   ", GOOD, &PasswordContext::empty(), db)
365            .await
366            .unwrap_err();
367        assert!(error.to_string().contains("username must not be empty"));
368    }
369
370    #[tokio::test]
371    async fn create_user_refuses_a_duplicate_in_words_not_a_unique_violation() {
372        let db = db().await;
373        create_user("alice", GOOD, &PasswordContext::empty(), db.clone())
374            .await
375            .unwrap();
376        let error = create_user("ALICE", GOOD, &PasswordContext::empty(), db)
377            .await
378            .unwrap_err();
379        assert!(matches!(error, UserError::DuplicateUsername(_)));
380        assert_eq!(
381            error.to_string(),
382            "an admin user named `alice` already exists"
383        );
384    }
385
386    #[tokio::test]
387    async fn set_password_revokes_every_session_of_that_user() {
388        let db = db().await;
389        let user = user_with_cheap_password("alice", "old-password", 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        assert!(
405            set_password("alice", GOOD, &PasswordContext::empty(), db.clone())
406                .await
407                .unwrap()
408                .is_some()
409        );
410        assert!(
411            AdminSession::list_all(Some(&user.id), &db)
412                .await
413                .unwrap()
414                .is_empty(),
415            "a password change that left sessions alive would be a change in name only"
416        );
417
418        let reloaded = AdminUser::find_by_username("alice", &db)
419            .await
420            .unwrap()
421            .unwrap();
422        assert_eq!(
423            password::verify_password(&reloaded.password_hash, GOOD),
424            Ok(true)
425        );
426    }
427
428    #[tokio::test]
429    async fn set_password_of_an_unknown_user_is_none_and_checks_the_policy_first() {
430        let db = db().await;
431        assert!(
432            set_password("nobody", GOOD, &PasswordContext::empty(), db.clone())
433                .await
434                .unwrap()
435                .is_none()
436        );
437        // The policy is checked before the lookup, so a bad password for an
438        // unknown user is a policy error rather than a silent `None`.
439        assert!(matches!(
440            set_password("nobody", "short", &PasswordContext::empty(), db)
441                .await
442                .unwrap_err(),
443            UserError::Policy(_)
444        ));
445    }
446
447    #[tokio::test]
448    async fn disabling_a_user_drops_their_sessions_but_enabling_does_not() {
449        let db = db().await;
450        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
451        AdminSession::create(
452            NewSession {
453                user_id: &user.id,
454                token_hash: "hash-a",
455                csrf_token: "csrf",
456                created_ip: None,
457                user_agent: None,
458            },
459            std::time::Duration::from_secs(60),
460            &db,
461        )
462        .await
463        .unwrap();
464
465        set_status("alice", "disabled", db.clone()).await.unwrap();
466        assert!(
467            AdminSession::list_all(Some(&user.id), &db)
468                .await
469                .unwrap()
470                .is_empty()
471        );
472
473        // Re-enabling is just a status change; there is nothing to drop.
474        let reenabled = set_status("alice", "active", db.clone())
475            .await
476            .unwrap()
477            .unwrap();
478        assert!(reenabled.is_active());
479        assert!(
480            set_status("nobody", "disabled", db)
481                .await
482                .unwrap()
483                .is_none()
484        );
485    }
486
487    #[tokio::test]
488    async fn revoke_sessions_counts_what_it_removed() {
489        let db = db().await;
490        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
491        for hash in ["a", "b"] {
492            AdminSession::create(
493                NewSession {
494                    user_id: &user.id,
495                    token_hash: hash,
496                    csrf_token: "csrf",
497                    created_ip: None,
498                    user_agent: None,
499                },
500                std::time::Duration::from_secs(60),
501                &db,
502            )
503            .await
504            .unwrap();
505        }
506
507        assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(2));
508        assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(0));
509        assert_eq!(revoke_sessions("nobody", db).await.unwrap(), None);
510    }
511
512    #[tokio::test]
513    async fn delete_user_removes_the_row_and_reports_whether_it_existed() {
514        let db = db().await;
515        user_with_cheap_password("alice", "pw", db.clone()).await;
516        assert!(delete_user("alice", db.clone()).await.unwrap());
517        assert!(!delete_user("alice", db).await.unwrap());
518    }
519
520    #[tokio::test]
521    async fn confirm_delete_user_covers_its_three_outcomes() {
522        let db = db().await;
523        let mut empty: &[u8] = &[];
524        assert_eq!(
525            confirm_delete_user("nobody", true, &mut empty, db.clone())
526                .await
527                .unwrap(),
528            DeleteOutcome::NotFound
529        );
530
531        user_with_cheap_password("alice", "pw", db.clone()).await;
532        let mut no = b"n\n".as_slice();
533        assert_eq!(
534            confirm_delete_user("alice", false, &mut no, db.clone())
535                .await
536                .unwrap(),
537            DeleteOutcome::Cancelled
538        );
539        assert!(
540            AdminUser::find_by_username("alice", &db)
541                .await
542                .unwrap()
543                .is_some()
544        );
545
546        let mut empty: &[u8] = &[];
547        assert_eq!(
548            confirm_delete_user("alice", true, &mut empty, db.clone())
549                .await
550                .unwrap(),
551            DeleteOutcome::Deleted
552        );
553        assert!(
554            AdminUser::find_by_username("alice", &db)
555                .await
556                .unwrap()
557                .is_none()
558        );
559    }
560
561    #[tokio::test]
562    async fn authenticate_accepts_the_right_password() {
563        let db = db().await;
564        user_with_cheap_password("alice", "the-password", db.clone()).await;
565        let outcome = authenticate("alice", "the-password", db).await.unwrap();
566        assert!(matches!(outcome, AuthOutcome::Authenticated(_)));
567    }
568
569    #[tokio::test]
570    async fn authenticate_distinguishes_its_failures_for_the_log() {
571        let db = db().await;
572        user_with_cheap_password("alice", "the-password", db.clone()).await;
573
574        assert!(matches!(
575            authenticate("alice", "wrong", db.clone()).await.unwrap(),
576            AuthOutcome::WrongPassword(_)
577        ));
578        assert!(matches!(
579            authenticate("nobody", "the-password", db.clone())
580                .await
581                .unwrap(),
582            AuthOutcome::UnknownUser
583        ));
584
585        set_status("alice", "disabled", db.clone()).await.unwrap();
586        assert!(matches!(
587            authenticate("alice", "the-password", db).await.unwrap(),
588            AuthOutcome::Disabled(_)
589        ));
590    }
591
592    #[tokio::test]
593    async fn authenticate_is_case_insensitive_in_the_username() {
594        let db = db().await;
595        user_with_cheap_password("alice", "the-password", db.clone()).await;
596        assert!(matches!(
597            authenticate("ALICE", "the-password", db).await.unwrap(),
598            AuthOutcome::Authenticated(_)
599        ));
600    }
601
602    #[tokio::test]
603    async fn a_successful_login_rehashes_a_row_written_under_older_parameters() {
604        let db = db().await;
605        let before = user_with_cheap_password("alice", "the-password", db.clone()).await;
606        assert!(password::needs_rehash(&before.password_hash));
607
608        let outcome = authenticate("alice", "the-password", db.clone())
609            .await
610            .unwrap();
611        let AuthOutcome::Authenticated(user) = outcome else {
612            panic!("expected a successful authentication");
613        };
614        assert!(!password::needs_rehash(&user.password_hash));
615
616        // Persisted, not just updated in memory -- and the new digest still
617        // verifies against the same password.
618        let reloaded = AdminUser::find_by_username("alice", &db)
619            .await
620            .unwrap()
621            .unwrap();
622        assert!(!password::needs_rehash(&reloaded.password_hash));
623        assert_eq!(
624            password::verify_password(&reloaded.password_hash, "the-password"),
625            Ok(true)
626        );
627    }
628
629    #[tokio::test]
630    async fn a_corrupt_stored_hash_refuses_the_login_rather_than_erroring() {
631        let db = db().await;
632        AdminUser::create("alice", "not-a-valid-encoded-hash", &db)
633            .await
634            .unwrap();
635        // Not an Err: a mangled row must not take the whole login endpoint
636        // down, and it must not read as "correct password" either.
637        assert!(matches!(
638            authenticate("alice", "anything", db).await.unwrap(),
639            AuthOutcome::WrongPassword(_)
640        ));
641    }
642
643    #[test]
644    fn every_user_error_renders() {
645        assert!(
646            UserError::Database(sqlx::Error::RowNotFound)
647                .to_string()
648                .starts_with("database error:")
649        );
650        assert_eq!(
651            UserError::Policy("too short".to_string()).to_string(),
652            "too short"
653        );
654        assert_eq!(
655            UserError::DuplicateUsername("bob".to_string()).to_string(),
656            "an admin user named `bob` already exists"
657        );
658    }
659}