1use std::io::BufRead;
11use std::sync::Arc;
12
13use tracing::{info, warn};
14
15use crate::admin::password::{self, PasswordContext};
16use crate::admin::prompt::confirm;
17use acme_proxy_store::admin_session::AdminSession;
18use acme_proxy_store::admin_user::AdminRole;
19use acme_proxy_store::admin_user::AdminStatus;
20use acme_proxy_store::admin_user::AdminUser;
21use acme_proxy_store::db::Database;
22
23#[derive(Debug, thiserror::Error)]
25pub enum UserError {
26 #[error("database error: {0}")]
27 Database(sqlx::Error),
28 #[error("{0}")]
31 Policy(String),
32 #[error("an admin user named `{0}` already exists")]
35 DuplicateUsername(String),
36 #[error("{0}")]
38 InvalidContact(String),
39}
40
41impl From<sqlx::Error> for UserError {
42 fn from(error: sqlx::Error) -> Self {
43 Self::Database(error)
44 }
45}
46
47#[derive(Debug)]
55pub enum AuthOutcome {
56 Authenticated(Box<AdminUser>),
58 UnknownUser,
60 WrongPassword(Box<AdminUser>),
62 Disabled(Box<AdminUser>),
64}
65
66#[must_use]
84pub fn valid_username(username: &str) -> bool {
85 !username.is_empty()
86 && username
87 .chars()
88 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
89}
90
91pub async fn create_user(
105 username: &str,
106 plaintext: &str,
107 context: &PasswordContext,
108 role: Option<AdminRole>,
109 database: Arc<Database>,
110) -> Result<AdminUser, UserError> {
111 password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
112
113 let normalized = username.trim().to_lowercase();
114 if normalized.is_empty() {
115 return Err(UserError::Policy("username must not be empty".to_string()));
116 }
117 if !valid_username(&normalized) {
118 return Err(UserError::Policy(format!(
119 "invalid username `{normalized}`: use lowercase letters, digits, `-`, `_` and `.` \
120 (the name is a URL segment on the web admin)"
121 )));
122 }
123 if AdminUser::find_by_username(&normalized, &database)
124 .await?
125 .is_some()
126 {
127 return Err(UserError::DuplicateUsername(normalized));
128 }
129
130 let hash = password::hash_password(plaintext);
131 Ok(AdminUser::create(&normalized, &hash, role, &database).await?)
132}
133
134pub async fn set_role(
150 username: &str,
151 role: AdminRole,
152 database: Arc<Database>,
153) -> Result<Option<(AdminUser, u64)>, UserError> {
154 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
155 return Ok(None);
156 };
157
158 if user.role() == AdminRole::Admin && role != AdminRole::Admin {
159 let admins = AdminUser::list_all(&database)
160 .await?
161 .iter()
162 .filter(|other| other.role() == AdminRole::Admin)
163 .count();
164 if admins <= 1 {
165 return Err(UserError::Policy(format!(
166 "`{}` is the only admin: demoting them would leave the panel with no one who \
167 can manage operators. Promote somebody else first.",
168 user.username
169 )));
170 }
171 }
172
173 user.set_role(role, &database).await?;
174 let revoked = AdminSession::delete_for_user(user.id, &database).await?;
175 Ok(Some((user, revoked)))
176}
177
178pub async fn set_contact_email(
186 username: &str,
187 contact: Option<&str>,
188 database: Arc<Database>,
189) -> Result<Option<AdminUser>, UserError> {
190 let trimmed = contact.map(str::trim).filter(|value| !value.is_empty());
191 if let Some(address) = trimmed {
192 address
193 .parse::<lettre::message::Mailbox>()
194 .map_err(|error| {
195 UserError::InvalidContact(format!(
196 "`{address}` is not a valid email address: {error}"
197 ))
198 })?;
199 }
200
201 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
202 return Ok(None);
203 };
204 user.set_contact_email(trimmed, &database).await?;
205 Ok(Some(user))
206}
207
208pub async fn list_users(
213 limit: i64,
214 offset: i64,
215 database: Arc<Database>,
216) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
217 AdminUser::search(limit, offset, &database).await
218}
219
220pub async fn set_password(
227 username: &str,
228 plaintext: &str,
229 context: &PasswordContext,
230 database: Arc<Database>,
231) -> Result<Option<(AdminUser, u64)>, UserError> {
232 password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
233
234 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
235 return Ok(None);
236 };
237
238 let hash = password::hash_password(plaintext);
239 user.set_password_hash(&hash, &database).await?;
240 let revoked = AdminSession::delete_for_user(user.id, &database).await?;
241 Ok(Some((user, revoked)))
242}
243
244pub async fn change_own_password(
259 user: &mut AdminUser,
260 new_password: &str,
261 context: &PasswordContext,
262 keep_session: &str,
263 database: Arc<Database>,
264) -> Result<(), UserError> {
265 password::check_password_policy(new_password, context).map_err(UserError::Policy)?;
266
267 let hash = password::hash_password(new_password);
268 user.set_password_hash(&hash, &database).await?;
269 AdminSession::delete_for_user_except(user.id, keep_session, &database).await?;
270 Ok(())
271}
272
273pub async fn set_status(
288 username: &str,
289 status: AdminStatus,
290 database: Arc<Database>,
291) -> Result<Option<(AdminUser, u64)>, sqlx::Error> {
292 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
293 return Ok(None);
294 };
295
296 user.set_status(status.as_str(), &database).await?;
297 let revoked = if status == AdminStatus::Disabled {
298 AdminSession::delete_for_user(user.id, &database).await?
299 } else {
300 0
301 };
302 Ok(Some((user, revoked)))
303}
304
305pub async fn operators_without_a_contact(database: Arc<Database>) -> Result<usize, sqlx::Error> {
316 Ok(AdminUser::list_all(&database)
317 .await?
318 .iter()
319 .filter(|user| user.contact_email.is_none())
320 .count())
321}
322
323pub async fn revoke_sessions(
326 username: &str,
327 database: Arc<Database>,
328) -> Result<Option<u64>, sqlx::Error> {
329 let Some(user) = AdminUser::find_by_username(username, &database).await? else {
330 return Ok(None);
331 };
332 Ok(Some(
333 AdminSession::delete_for_user(user.id, &database).await?,
334 ))
335}
336
337pub async fn delete_user(username: &str, database: Arc<Database>) -> Result<bool, sqlx::Error> {
343 let Some(user) = AdminUser::find_by_username(username, &database).await? else {
344 return Ok(false);
345 };
346 AdminUser::delete(user.id, &database).await
347}
348
349#[derive(Debug, PartialEq, Eq)]
355pub enum UserDeleteOutcome {
356 NotFound,
357 Cancelled,
358 Deleted(crate::admin::ops::Deleted),
360}
361
362pub async fn confirm_delete_user(
364 username: &str,
365 assume_yes: bool,
366 reader: &mut impl BufRead,
367 database: Arc<Database>,
368) -> Result<UserDeleteOutcome, sqlx::Error> {
369 let Some(user) = AdminUser::find_by_username(username, &database).await? else {
370 return Ok(UserDeleteOutcome::NotFound);
371 };
372 let sessions = AdminSession::list_all(Some(user.id), &database)
373 .await?
374 .len();
375 let prompt = format!(
376 "Delete admin user {} (status: {}, {sessions} session(s) will cascade)?",
377 user.username, user.status
378 );
379 if !confirm(&prompt, assume_yes, reader) {
380 return Ok(UserDeleteOutcome::Cancelled);
381 }
382 AdminUser::delete(user.id, &database).await?;
383 Ok(UserDeleteOutcome::Deleted(crate::admin::ops::Deleted {
384 cascaded: sessions as u64,
385 }))
386}
387
388pub async fn authenticate(
400 username: &str,
401 plaintext: &str,
402 database: Arc<Database>,
403) -> Result<AuthOutcome, sqlx::Error> {
404 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
405 let _ = password::verify_password_off_runtime(password::dummy_hash(), plaintext).await;
407 return Ok(AuthOutcome::UnknownUser);
408 };
409
410 let verified = match password::verify_password_off_runtime(&user.password_hash, plaintext).await
411 {
412 Ok(verified) => verified,
413 Err(error) => {
414 warn!(event = "admin_password_hash_unreadable",
418 outcome = "failure",
419 user_id = %user.id,
420 username = %user.username,
421 error = %error,
422 "stored password hash could not be decoded; \
423 run `acme-proxy admin user passwd` to rewrite it");
424 return Ok(AuthOutcome::WrongPassword(Box::new(user)));
425 }
426 };
427
428 if !verified {
429 return Ok(AuthOutcome::WrongPassword(Box::new(user)));
430 }
431 if !user.is_active() {
432 return Ok(AuthOutcome::Disabled(Box::new(user)));
433 }
434
435 if password::needs_rehash(&user.password_hash)
439 && let Some(rehashed) = password::hash_password_off_runtime(plaintext).await
440 {
441 user.set_password_hash(&rehashed, &database).await?;
442 info!(event = "admin_password_rehashed", outcome = "success", user_id = %user.id);
443 }
444
445 Ok(AuthOutcome::Authenticated(Box::new(user)))
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use acme_proxy_store::admin_session::NewSession;
452
453 const GOOD: &str = "a-long-enough-password";
454
455 async fn db() -> Arc<Database> {
456 Arc::new(Database::connect_in_memory().await.unwrap())
457 }
458
459 async fn user_with_cheap_password(
463 username: &str,
464 plaintext: &str,
465 database: Arc<Database>,
466 ) -> AdminUser {
467 let salt = [3u8; 16];
471 let mut digest = [0u8; 32];
472 ring::pbkdf2::derive(
473 ring::pbkdf2::PBKDF2_HMAC_SHA256,
474 std::num::NonZeroU32::new(1).unwrap(),
475 &salt,
476 plaintext.as_bytes(),
477 &mut digest,
478 );
479 let encoded = format!(
480 "pbkdf2-sha256$1${}${}",
481 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, salt),
482 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, digest),
483 );
484 AdminUser::create(username, &encoded, None, &database)
485 .await
486 .unwrap()
487 }
488
489 #[tokio::test]
490 async fn set_contact_email_stores_clears_and_validates() {
491 let db = db().await;
492 create_user("alice", GOOD, &PasswordContext::empty(), None, db.clone())
493 .await
494 .unwrap();
495
496 assert!(
498 set_contact_email("nobody", Some("x@example.com"), db.clone())
499 .await
500 .unwrap()
501 .is_none()
502 );
503
504 let error = set_contact_email("alice", Some("not an address"), db.clone())
506 .await
507 .unwrap_err();
508 assert!(matches!(error, UserError::InvalidContact(_)), "{error}");
509
510 let user = set_contact_email("alice", Some(" alice@example.com "), db.clone())
512 .await
513 .unwrap()
514 .unwrap();
515 assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
516
517 for cleared in [Some(" "), None] {
519 let user = set_contact_email("alice", cleared, db.clone())
520 .await
521 .unwrap()
522 .unwrap();
523 assert_eq!(user.contact_email, None);
524 set_contact_email("alice", Some("alice@example.com"), db.clone())
525 .await
526 .unwrap();
527 }
528 }
529
530 #[tokio::test]
531 async fn create_user_normalizes_and_hashes() {
532 let db = db().await;
533 let user = create_user(
534 " Alice ",
535 GOOD,
536 &PasswordContext::empty(),
537 None,
538 db.clone(),
539 )
540 .await
541 .unwrap();
542 assert_eq!(user.username, "alice");
543 assert!(user.is_active());
544 assert!(!user.password_hash.contains(GOOD));
546 assert_eq!(
547 password::verify_password(&user.password_hash, GOOD),
548 Ok(true)
549 );
550 }
551
552 #[tokio::test]
553 async fn create_user_refuses_a_password_below_the_policy() {
554 let db = db().await;
555 let error = create_user(
556 "alice",
557 "short",
558 &PasswordContext::empty(),
559 None,
560 db.clone(),
561 )
562 .await
563 .unwrap_err();
564 assert!(matches!(error, UserError::Policy(_)));
565 assert!(error.to_string().contains("at least 12"));
566 assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
568 }
569
570 #[tokio::test]
574 async fn create_user_refuses_a_common_password_and_a_deployment_word() {
575 let db = db().await;
576
577 let error = create_user(
578 "alice",
579 "passwordpassword",
580 &PasswordContext::empty(),
581 None,
582 db.clone(),
583 )
584 .await
585 .unwrap_err();
586 assert!(matches!(error, UserError::Policy(_)));
587 assert!(error.to_string().contains("commonly used"));
588
589 let mut config = acme_proxy_core::config::Config::default();
590 config.server.base_url = "https://ca.example.com".to_string();
591 let context = PasswordContext::from_config(&config, "alice");
592 let error = create_user("alice", "acmeproxy2026!!", &context, None, db.clone())
593 .await
594 .unwrap_err();
595 assert!(matches!(error, UserError::Policy(_)));
596 assert!(error.to_string().contains("names this deployment"));
597
598 assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
600 }
601
602 #[tokio::test]
605 async fn set_password_refuses_a_common_password_before_looking_the_user_up() {
606 let db = db().await;
607 let error = set_password(
608 "nobody",
609 "passwordpassword",
610 &PasswordContext::empty(),
611 db.clone(),
612 )
613 .await
614 .unwrap_err();
615 assert!(error.to_string().contains("commonly used"), "got: {error}");
616 }
617
618 #[tokio::test]
619 async fn create_user_refuses_an_empty_username() {
620 let db = db().await;
621 let error = create_user(" ", GOOD, &PasswordContext::empty(), None, db)
622 .await
623 .unwrap_err();
624 assert!(error.to_string().contains("username must not be empty"));
625 }
626
627 #[tokio::test]
628 async fn create_user_refuses_a_duplicate_in_words_not_a_unique_violation() {
629 let db = db().await;
630 create_user("alice", GOOD, &PasswordContext::empty(), None, db.clone())
631 .await
632 .unwrap();
633 let error = create_user("ALICE", GOOD, &PasswordContext::empty(), None, db)
634 .await
635 .unwrap_err();
636 assert!(matches!(error, UserError::DuplicateUsername(_)));
637 assert_eq!(
638 error.to_string(),
639 "an admin user named `alice` already exists"
640 );
641 }
642
643 #[tokio::test]
644 async fn set_password_revokes_every_session_of_that_user() {
645 let db = db().await;
646 let user = user_with_cheap_password("alice", "old-password", db.clone()).await;
647 AdminSession::create(
648 NewSession {
649 user_id: user.id,
650 token_hash: "hash-a",
651 csrf_token: "csrf",
652 created_ip: None,
653 user_agent: None,
654 },
655 std::time::Duration::from_secs(60),
656 &db,
657 )
658 .await
659 .unwrap();
660
661 assert!(
662 set_password("alice", GOOD, &PasswordContext::empty(), db.clone())
663 .await
664 .unwrap()
665 .is_some()
666 );
667 assert!(
668 AdminSession::list_all(Some(user.id), &db)
669 .await
670 .unwrap()
671 .is_empty(),
672 "a password change that left sessions alive would be a change in name only"
673 );
674
675 let reloaded = AdminUser::find_by_username("alice", &db)
676 .await
677 .unwrap()
678 .unwrap();
679 assert_eq!(
680 password::verify_password(&reloaded.password_hash, GOOD),
681 Ok(true)
682 );
683 }
684
685 #[tokio::test]
690 async fn change_own_password_keeps_the_calling_session_and_drops_every_other() {
691 let db = db().await;
692 let mut user = user_with_cheap_password("alice", "old-password", db.clone()).await;
693
694 let kept = AdminSession::create(
695 NewSession {
696 user_id: user.id,
697 token_hash: "kept-hash",
698 csrf_token: "csrf",
699 created_ip: None,
700 user_agent: None,
701 },
702 std::time::Duration::from_secs(3600),
703 &db,
704 )
705 .await
706 .unwrap();
707 AdminSession::create(
708 NewSession {
709 user_id: user.id,
710 token_hash: "other-hash",
711 csrf_token: "csrf",
712 created_ip: None,
713 user_agent: None,
714 },
715 std::time::Duration::from_secs(3600),
716 &db,
717 )
718 .await
719 .unwrap();
720
721 change_own_password(
722 &mut user,
723 GOOD,
724 &PasswordContext::empty(),
725 &kept.token_hash,
726 db.clone(),
727 )
728 .await
729 .unwrap();
730
731 let live = AdminSession::list_all(Some(user.id), &db).await.unwrap();
732 assert_eq!(live.len(), 1, "every other session must be revoked");
733 assert_eq!(live[0].token_hash, "kept-hash");
734
735 let reloaded = AdminUser::find_by_username("alice", &db)
736 .await
737 .unwrap()
738 .unwrap();
739 assert_eq!(
740 password::verify_password(&reloaded.password_hash, GOOD),
741 Ok(true)
742 );
743 assert_eq!(
744 password::verify_password(&reloaded.password_hash, "old-password"),
745 Ok(false)
746 );
747 }
748
749 #[tokio::test]
752 async fn change_own_password_checks_the_policy() {
753 let db = db().await;
754 let mut user = user_with_cheap_password("alice", "old-password", db.clone()).await;
755 let before = user.password_hash.clone();
756
757 let error = change_own_password(
758 &mut user,
759 "short",
760 &PasswordContext::empty(),
761 "kept-hash",
762 db.clone(),
763 )
764 .await
765 .unwrap_err();
766 assert!(matches!(error, UserError::Policy(_)));
767
768 let reloaded = AdminUser::find_by_username("alice", &db)
769 .await
770 .unwrap()
771 .unwrap();
772 assert_eq!(reloaded.password_hash, before);
773 }
774
775 #[tokio::test]
776 async fn set_password_of_an_unknown_user_is_none_and_checks_the_policy_first() {
777 let db = db().await;
778 assert!(
779 set_password("nobody", GOOD, &PasswordContext::empty(), db.clone())
780 .await
781 .unwrap()
782 .is_none()
783 );
784 assert!(matches!(
787 set_password("nobody", "short", &PasswordContext::empty(), db)
788 .await
789 .unwrap_err(),
790 UserError::Policy(_)
791 ));
792 }
793
794 #[tokio::test]
795 async fn disabling_a_user_drops_their_sessions_but_enabling_does_not() {
796 let db = db().await;
797 let user = user_with_cheap_password("alice", "pw", db.clone()).await;
798 AdminSession::create(
799 NewSession {
800 user_id: user.id,
801 token_hash: "hash-a",
802 csrf_token: "csrf",
803 created_ip: None,
804 user_agent: None,
805 },
806 std::time::Duration::from_secs(60),
807 &db,
808 )
809 .await
810 .unwrap();
811
812 set_status("alice", AdminStatus::Disabled, db.clone())
813 .await
814 .unwrap();
815 assert!(
816 AdminSession::list_all(Some(user.id), &db)
817 .await
818 .unwrap()
819 .is_empty()
820 );
821
822 let reenabled = set_status("alice", AdminStatus::Active, db.clone())
824 .await
825 .unwrap()
826 .unwrap();
827 assert!(reenabled.0.is_active());
828 assert!(
829 set_status("nobody", AdminStatus::Disabled, db)
830 .await
831 .unwrap()
832 .is_none()
833 );
834 }
835
836 #[tokio::test]
837 async fn set_role_changes_the_tier_and_revokes_every_session() {
838 let db = db().await;
839 let user = user_with_cheap_password("alice", "pw", db.clone()).await;
840 assert_eq!(user.role(), AdminRole::Admin, "a fresh row reads as admin");
841 user_with_cheap_password("root", "pw", db.clone()).await;
844 AdminSession::create(
845 NewSession {
846 user_id: user.id,
847 token_hash: "hash-a",
848 csrf_token: "csrf",
849 created_ip: None,
850 user_agent: None,
851 },
852 std::time::Duration::from_secs(60),
853 &db,
854 )
855 .await
856 .unwrap();
857
858 let updated = set_role("alice", AdminRole::Viewer, db.clone())
859 .await
860 .unwrap()
861 .unwrap();
862 assert_eq!(updated.0.role(), AdminRole::Viewer);
863 assert!(
864 AdminSession::list_all(Some(user.id), &db)
865 .await
866 .unwrap()
867 .is_empty(),
868 "a demotion that left an admin session live would take effect only on expiry"
869 );
870
871 assert!(
872 set_role("nobody", AdminRole::Operator, db)
873 .await
874 .unwrap()
875 .is_none()
876 );
877 }
878
879 #[tokio::test]
883 async fn demoting_the_last_admin_is_refused() {
884 let db = db().await;
885 user_with_cheap_password("alice", "pw", db.clone()).await;
886
887 let error = set_role("alice", AdminRole::Viewer, db.clone())
888 .await
889 .expect_err("the only admin cannot be demoted");
890 assert!(error.to_string().contains("only admin"), "{error}");
891 assert_eq!(
893 AdminUser::find_by_username("alice", &db)
894 .await
895 .unwrap()
896 .unwrap()
897 .role(),
898 AdminRole::Admin
899 );
900
901 user_with_cheap_password("root", "pw", db.clone()).await;
903 let (updated, _) = set_role("alice", AdminRole::Viewer, db.clone())
904 .await
905 .unwrap()
906 .unwrap();
907 assert_eq!(updated.role(), AdminRole::Viewer);
908
909 assert!(set_role("alice", AdminRole::Admin, db).await.is_ok());
911 }
912
913 #[tokio::test]
914 async fn revoke_sessions_counts_what_it_removed() {
915 let db = db().await;
916 let user = user_with_cheap_password("alice", "pw", db.clone()).await;
917 for hash in ["a", "b"] {
918 AdminSession::create(
919 NewSession {
920 user_id: user.id,
921 token_hash: hash,
922 csrf_token: "csrf",
923 created_ip: None,
924 user_agent: None,
925 },
926 std::time::Duration::from_secs(60),
927 &db,
928 )
929 .await
930 .unwrap();
931 }
932
933 assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(2));
934 assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(0));
935 assert_eq!(revoke_sessions("nobody", db).await.unwrap(), None);
936 }
937
938 #[tokio::test]
939 async fn delete_user_removes_the_row_and_reports_whether_it_existed() {
940 let db = db().await;
941 user_with_cheap_password("alice", "pw", db.clone()).await;
942 assert!(delete_user("alice", db.clone()).await.unwrap());
943 assert!(!delete_user("alice", db).await.unwrap());
944 }
945
946 #[tokio::test]
947 async fn confirm_delete_user_covers_its_three_outcomes() {
948 let db = db().await;
949 let mut empty: &[u8] = &[];
950 assert_eq!(
951 confirm_delete_user("nobody", true, &mut empty, db.clone())
952 .await
953 .unwrap(),
954 UserDeleteOutcome::NotFound
955 );
956
957 user_with_cheap_password("alice", "pw", db.clone()).await;
958 let mut no = b"n\n".as_slice();
959 assert_eq!(
960 confirm_delete_user("alice", false, &mut no, db.clone())
961 .await
962 .unwrap(),
963 UserDeleteOutcome::Cancelled
964 );
965 assert!(
966 AdminUser::find_by_username("alice", &db)
967 .await
968 .unwrap()
969 .is_some()
970 );
971
972 let mut empty: &[u8] = &[];
973 assert_eq!(
974 confirm_delete_user("alice", true, &mut empty, db.clone())
975 .await
976 .unwrap(),
977 UserDeleteOutcome::Deleted(crate::admin::ops::Deleted { cascaded: 0 })
978 );
979 assert!(
980 AdminUser::find_by_username("alice", &db)
981 .await
982 .unwrap()
983 .is_none()
984 );
985 }
986
987 #[tokio::test]
988 async fn authenticate_accepts_the_right_password() {
989 let db = db().await;
990 user_with_cheap_password("alice", "the-password", db.clone()).await;
991 let outcome = authenticate("alice", "the-password", db).await.unwrap();
992 assert!(matches!(outcome, AuthOutcome::Authenticated(_)));
993 }
994
995 #[tokio::test]
996 async fn authenticate_distinguishes_its_failures_for_the_log() {
997 let db = db().await;
998 user_with_cheap_password("alice", "the-password", db.clone()).await;
999
1000 assert!(matches!(
1001 authenticate("alice", "wrong", db.clone()).await.unwrap(),
1002 AuthOutcome::WrongPassword(_)
1003 ));
1004 assert!(matches!(
1005 authenticate("nobody", "the-password", db.clone())
1006 .await
1007 .unwrap(),
1008 AuthOutcome::UnknownUser
1009 ));
1010
1011 set_status("alice", AdminStatus::Disabled, db.clone())
1012 .await
1013 .unwrap();
1014 assert!(matches!(
1015 authenticate("alice", "the-password", db).await.unwrap(),
1016 AuthOutcome::Disabled(_)
1017 ));
1018 }
1019
1020 #[tokio::test]
1021 async fn authenticate_is_case_insensitive_in_the_username() {
1022 let db = db().await;
1023 user_with_cheap_password("alice", "the-password", db.clone()).await;
1024 assert!(matches!(
1025 authenticate("ALICE", "the-password", db).await.unwrap(),
1026 AuthOutcome::Authenticated(_)
1027 ));
1028 }
1029
1030 #[tokio::test]
1031 async fn a_successful_login_rehashes_a_row_written_under_older_parameters() {
1032 let db = db().await;
1033 let before = user_with_cheap_password("alice", "the-password", db.clone()).await;
1034 assert!(password::needs_rehash(&before.password_hash));
1035
1036 let outcome = authenticate("alice", "the-password", db.clone())
1037 .await
1038 .unwrap();
1039 let AuthOutcome::Authenticated(user) = outcome else {
1040 panic!("expected a successful authentication");
1041 };
1042 assert!(!password::needs_rehash(&user.password_hash));
1043
1044 let reloaded = AdminUser::find_by_username("alice", &db)
1047 .await
1048 .unwrap()
1049 .unwrap();
1050 assert!(!password::needs_rehash(&reloaded.password_hash));
1051 assert_eq!(
1052 password::verify_password(&reloaded.password_hash, "the-password"),
1053 Ok(true)
1054 );
1055 }
1056
1057 #[tokio::test]
1058 async fn a_corrupt_stored_hash_refuses_the_login_rather_than_erroring() {
1059 let db = db().await;
1060 AdminUser::create("alice", "not-a-valid-encoded-hash", None, &db)
1061 .await
1062 .unwrap();
1063 assert!(matches!(
1066 authenticate("alice", "anything", db).await.unwrap(),
1067 AuthOutcome::WrongPassword(_)
1068 ));
1069 }
1070
1071 #[test]
1072 fn every_user_error_renders() {
1073 assert!(
1074 UserError::Database(sqlx::Error::RowNotFound)
1075 .to_string()
1076 .starts_with("database error:")
1077 );
1078 assert_eq!(
1079 UserError::Policy("too short".to_string()).to_string(),
1080 "too short"
1081 );
1082 assert_eq!(
1083 UserError::DuplicateUsername("bob".to_string()).to_string(),
1084 "an admin user named `bob` already exists"
1085 );
1086 }
1087}