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(database: Arc<Database>) -> Result<Vec<AdminUser>, sqlx::Error> {
91 AdminUser::list_all(&database).await
92}
93
94pub 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
117pub 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
137pub 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
151pub 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
163pub 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
187pub 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 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 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 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 async fn user_with_cheap_password(
260 username: &str,
261 plaintext: &str,
262 database: Arc<Database>,
263 ) -> AdminUser {
264 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 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 assert!(list_users(db).await.unwrap().is_empty());
312 }
313
314 #[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 assert!(list_users(db).await.unwrap().is_empty());
343 }
344
345 #[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 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 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 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 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}