1use 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#[derive(Debug, thiserror::Error)]
23pub enum UserError {
24 #[error("database error: {0}")]
25 Database(sqlx::Error),
26 #[error("{0}")]
29 Policy(String),
30 #[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#[derive(Debug)]
50pub enum AuthOutcome {
51 Authenticated(Box<AdminUser>),
53 UnknownUser,
55 WrongPassword(Box<AdminUser>),
57 Disabled(Box<AdminUser>),
59}
60
61pub 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
89pub 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
101pub 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
124pub 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
144pub 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
158pub 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
170pub 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
194pub 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 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 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 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 async fn user_with_cheap_password(
267 username: &str,
268 plaintext: &str,
269 database: Arc<Database>,
270 ) -> AdminUser {
271 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 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 assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
319 }
320
321 #[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 assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
350 }
351
352 #[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 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 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 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 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}