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