1use std::io::BufRead;
10use std::sync::Arc;
11
12use tracing::{info, warn};
13
14use crate::admin::ops::DeleteOutcome;
15use crate::admin::password;
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 database: Arc<Database>,
70) -> Result<AdminUser, UserError> {
71 password::check_password_policy(plaintext).map_err(UserError::Policy)?;
72
73 let normalized = username.trim().to_lowercase();
74 if normalized.is_empty() {
75 return Err(UserError::Policy("username must not be empty".to_string()));
76 }
77 if AdminUser::find_by_username(&normalized, &database)
78 .await?
79 .is_some()
80 {
81 return Err(UserError::DuplicateUsername(normalized));
82 }
83
84 let hash = password::hash_password(plaintext);
85 Ok(AdminUser::create(&normalized, &hash, &database).await?)
86}
87
88pub async fn list_users(database: Arc<Database>) -> Result<Vec<AdminUser>, sqlx::Error> {
90 AdminUser::list_all(&database).await
91}
92
93pub async fn set_password(
99 username: &str,
100 plaintext: &str,
101 database: Arc<Database>,
102) -> Result<Option<AdminUser>, UserError> {
103 password::check_password_policy(plaintext).map_err(UserError::Policy)?;
104
105 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
106 return Ok(None);
107 };
108
109 let hash = password::hash_password(plaintext);
110 user.set_password_hash(&hash, &database).await?;
111 AdminSession::delete_for_user(&user.id, &database).await?;
112 Ok(Some(user))
113}
114
115pub async fn set_status(
120 username: &str,
121 status: &str,
122 database: Arc<Database>,
123) -> Result<Option<AdminUser>, sqlx::Error> {
124 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
125 return Ok(None);
126 };
127
128 user.set_status(status, &database).await?;
129 if status == "disabled" {
130 AdminSession::delete_for_user(&user.id, &database).await?;
131 }
132 Ok(Some(user))
133}
134
135pub async fn revoke_sessions(
138 username: &str,
139 database: Arc<Database>,
140) -> Result<Option<u64>, sqlx::Error> {
141 let Some(user) = AdminUser::find_by_username(username, &database).await? else {
142 return Ok(None);
143 };
144 Ok(Some(
145 AdminSession::delete_for_user(&user.id, &database).await?,
146 ))
147}
148
149pub async fn delete_user(username: &str, database: Arc<Database>) -> Result<bool, sqlx::Error> {
155 let Some(user) = AdminUser::find_by_username(username, &database).await? else {
156 return Ok(false);
157 };
158 AdminUser::delete(&user.id, &database).await
159}
160
161pub async fn confirm_delete_user(
163 username: &str,
164 assume_yes: bool,
165 reader: &mut impl BufRead,
166 database: Arc<Database>,
167) -> Result<DeleteOutcome, sqlx::Error> {
168 let Some(user) = AdminUser::find_by_username(username, &database).await? else {
169 return Ok(DeleteOutcome::NotFound);
170 };
171 let sessions = AdminSession::list_all(Some(&user.id), &database)
172 .await?
173 .len();
174 let prompt = format!(
175 "Delete admin user {} (status: {}, {sessions} session(s) will cascade)?",
176 user.username, user.status
177 );
178 if !confirm(&prompt, assume_yes, reader) {
179 return Ok(DeleteOutcome::Cancelled);
180 }
181 AdminUser::delete(&user.id, &database).await?;
182 Ok(DeleteOutcome::Deleted)
183}
184
185pub async fn authenticate(
197 username: &str,
198 plaintext: &str,
199 database: Arc<Database>,
200) -> Result<AuthOutcome, sqlx::Error> {
201 let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
202 let _ = password::verify_password(password::dummy_hash(), plaintext);
204 return Ok(AuthOutcome::UnknownUser);
205 };
206
207 let verified = match password::verify_password(&user.password_hash, plaintext) {
208 Ok(verified) => verified,
209 Err(error) => {
210 warn!(event = "admin_password_hash_unreadable",
214 outcome = "failure",
215 user_id = %user.id,
216 username = %user.username,
217 error = %error,
218 "stored password hash could not be decoded; \
219 run `acme-proxy admin user passwd` to rewrite it");
220 return Ok(AuthOutcome::WrongPassword(Box::new(user)));
221 }
222 };
223
224 if !verified {
225 return Ok(AuthOutcome::WrongPassword(Box::new(user)));
226 }
227 if !user.is_active() {
228 return Ok(AuthOutcome::Disabled(Box::new(user)));
229 }
230
231 if password::needs_rehash(&user.password_hash) {
235 let rehashed = password::hash_password(plaintext);
236 user.set_password_hash(&rehashed, &database).await?;
237 info!(event = "admin_password_rehashed", outcome = "success", user_id = %user.id);
238 }
239
240 Ok(AuthOutcome::Authenticated(Box::new(user)))
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::sqlite::admin_session::NewSession;
247
248 const GOOD: &str = "a-long-enough-password";
249
250 async fn db() -> Arc<Database> {
251 Arc::new(Database::connect_in_memory().await.unwrap())
252 }
253
254 async fn user_with_cheap_password(
258 username: &str,
259 plaintext: &str,
260 database: Arc<Database>,
261 ) -> AdminUser {
262 let salt = [3u8; 16];
266 let mut digest = [0u8; 32];
267 ring::pbkdf2::derive(
268 ring::pbkdf2::PBKDF2_HMAC_SHA256,
269 std::num::NonZeroU32::new(1).unwrap(),
270 &salt,
271 plaintext.as_bytes(),
272 &mut digest,
273 );
274 let encoded = format!(
275 "pbkdf2-sha256$1${}${}",
276 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, salt),
277 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, digest),
278 );
279 AdminUser::create(username, &encoded, &database)
280 .await
281 .unwrap()
282 }
283
284 #[tokio::test]
285 async fn create_user_normalizes_and_hashes() {
286 let db = db().await;
287 let user = create_user(" Alice ", GOOD, db.clone()).await.unwrap();
288 assert_eq!(user.username, "alice");
289 assert!(user.is_active());
290 assert!(!user.password_hash.contains(GOOD));
292 assert_eq!(
293 password::verify_password(&user.password_hash, GOOD),
294 Ok(true)
295 );
296 }
297
298 #[tokio::test]
299 async fn create_user_refuses_a_password_below_the_policy() {
300 let db = db().await;
301 let error = create_user("alice", "short", db.clone()).await.unwrap_err();
302 assert!(matches!(error, UserError::Policy(_)));
303 assert!(error.to_string().contains("at least 12"));
304 assert!(list_users(db).await.unwrap().is_empty());
306 }
307
308 #[tokio::test]
309 async fn create_user_refuses_an_empty_username() {
310 let db = db().await;
311 let error = create_user(" ", GOOD, db).await.unwrap_err();
312 assert!(error.to_string().contains("username must not be empty"));
313 }
314
315 #[tokio::test]
316 async fn create_user_refuses_a_duplicate_in_words_not_a_unique_violation() {
317 let db = db().await;
318 create_user("alice", GOOD, db.clone()).await.unwrap();
319 let error = create_user("ALICE", GOOD, db).await.unwrap_err();
320 assert!(matches!(error, UserError::DuplicateUsername(_)));
321 assert_eq!(
322 error.to_string(),
323 "an admin user named `alice` already exists"
324 );
325 }
326
327 #[tokio::test]
328 async fn set_password_revokes_every_session_of_that_user() {
329 let db = db().await;
330 let user = user_with_cheap_password("alice", "old-password", db.clone()).await;
331 AdminSession::create(
332 NewSession {
333 user_id: &user.id,
334 token_hash: "hash-a",
335 csrf_token: "csrf",
336 created_ip: None,
337 user_agent: None,
338 },
339 std::time::Duration::from_secs(60),
340 &db,
341 )
342 .await
343 .unwrap();
344
345 assert!(
346 set_password("alice", GOOD, db.clone())
347 .await
348 .unwrap()
349 .is_some()
350 );
351 assert!(
352 AdminSession::list_all(Some(&user.id), &db)
353 .await
354 .unwrap()
355 .is_empty(),
356 "a password change that left sessions alive would be a change in name only"
357 );
358
359 let reloaded = AdminUser::find_by_username("alice", &db)
360 .await
361 .unwrap()
362 .unwrap();
363 assert_eq!(
364 password::verify_password(&reloaded.password_hash, GOOD),
365 Ok(true)
366 );
367 }
368
369 #[tokio::test]
370 async fn set_password_of_an_unknown_user_is_none_and_checks_the_policy_first() {
371 let db = db().await;
372 assert!(
373 set_password("nobody", GOOD, db.clone())
374 .await
375 .unwrap()
376 .is_none()
377 );
378 assert!(matches!(
381 set_password("nobody", "short", db).await.unwrap_err(),
382 UserError::Policy(_)
383 ));
384 }
385
386 #[tokio::test]
387 async fn disabling_a_user_drops_their_sessions_but_enabling_does_not() {
388 let db = db().await;
389 let user = user_with_cheap_password("alice", "pw", 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 set_status("alice", "disabled", db.clone()).await.unwrap();
405 assert!(
406 AdminSession::list_all(Some(&user.id), &db)
407 .await
408 .unwrap()
409 .is_empty()
410 );
411
412 let reenabled = set_status("alice", "active", db.clone())
414 .await
415 .unwrap()
416 .unwrap();
417 assert!(reenabled.is_active());
418 assert!(
419 set_status("nobody", "disabled", db)
420 .await
421 .unwrap()
422 .is_none()
423 );
424 }
425
426 #[tokio::test]
427 async fn revoke_sessions_counts_what_it_removed() {
428 let db = db().await;
429 let user = user_with_cheap_password("alice", "pw", db.clone()).await;
430 for hash in ["a", "b"] {
431 AdminSession::create(
432 NewSession {
433 user_id: &user.id,
434 token_hash: hash,
435 csrf_token: "csrf",
436 created_ip: None,
437 user_agent: None,
438 },
439 std::time::Duration::from_secs(60),
440 &db,
441 )
442 .await
443 .unwrap();
444 }
445
446 assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(2));
447 assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(0));
448 assert_eq!(revoke_sessions("nobody", db).await.unwrap(), None);
449 }
450
451 #[tokio::test]
452 async fn delete_user_removes_the_row_and_reports_whether_it_existed() {
453 let db = db().await;
454 user_with_cheap_password("alice", "pw", db.clone()).await;
455 assert!(delete_user("alice", db.clone()).await.unwrap());
456 assert!(!delete_user("alice", db).await.unwrap());
457 }
458
459 #[tokio::test]
460 async fn confirm_delete_user_covers_its_three_outcomes() {
461 let db = db().await;
462 let mut empty: &[u8] = &[];
463 assert_eq!(
464 confirm_delete_user("nobody", true, &mut empty, db.clone())
465 .await
466 .unwrap(),
467 DeleteOutcome::NotFound
468 );
469
470 user_with_cheap_password("alice", "pw", db.clone()).await;
471 let mut no = b"n\n".as_slice();
472 assert_eq!(
473 confirm_delete_user("alice", false, &mut no, db.clone())
474 .await
475 .unwrap(),
476 DeleteOutcome::Cancelled
477 );
478 assert!(
479 AdminUser::find_by_username("alice", &db)
480 .await
481 .unwrap()
482 .is_some()
483 );
484
485 let mut empty: &[u8] = &[];
486 assert_eq!(
487 confirm_delete_user("alice", true, &mut empty, db.clone())
488 .await
489 .unwrap(),
490 DeleteOutcome::Deleted
491 );
492 assert!(
493 AdminUser::find_by_username("alice", &db)
494 .await
495 .unwrap()
496 .is_none()
497 );
498 }
499
500 #[tokio::test]
501 async fn authenticate_accepts_the_right_password() {
502 let db = db().await;
503 user_with_cheap_password("alice", "the-password", db.clone()).await;
504 let outcome = authenticate("alice", "the-password", db).await.unwrap();
505 assert!(matches!(outcome, AuthOutcome::Authenticated(_)));
506 }
507
508 #[tokio::test]
509 async fn authenticate_distinguishes_its_failures_for_the_log() {
510 let db = db().await;
511 user_with_cheap_password("alice", "the-password", db.clone()).await;
512
513 assert!(matches!(
514 authenticate("alice", "wrong", db.clone()).await.unwrap(),
515 AuthOutcome::WrongPassword(_)
516 ));
517 assert!(matches!(
518 authenticate("nobody", "the-password", db.clone())
519 .await
520 .unwrap(),
521 AuthOutcome::UnknownUser
522 ));
523
524 set_status("alice", "disabled", db.clone()).await.unwrap();
525 assert!(matches!(
526 authenticate("alice", "the-password", db).await.unwrap(),
527 AuthOutcome::Disabled(_)
528 ));
529 }
530
531 #[tokio::test]
532 async fn authenticate_is_case_insensitive_in_the_username() {
533 let db = db().await;
534 user_with_cheap_password("alice", "the-password", db.clone()).await;
535 assert!(matches!(
536 authenticate("ALICE", "the-password", db).await.unwrap(),
537 AuthOutcome::Authenticated(_)
538 ));
539 }
540
541 #[tokio::test]
542 async fn a_successful_login_rehashes_a_row_written_under_older_parameters() {
543 let db = db().await;
544 let before = user_with_cheap_password("alice", "the-password", db.clone()).await;
545 assert!(password::needs_rehash(&before.password_hash));
546
547 let outcome = authenticate("alice", "the-password", db.clone())
548 .await
549 .unwrap();
550 let AuthOutcome::Authenticated(user) = outcome else {
551 panic!("expected a successful authentication");
552 };
553 assert!(!password::needs_rehash(&user.password_hash));
554
555 let reloaded = AdminUser::find_by_username("alice", &db)
558 .await
559 .unwrap()
560 .unwrap();
561 assert!(!password::needs_rehash(&reloaded.password_hash));
562 assert_eq!(
563 password::verify_password(&reloaded.password_hash, "the-password"),
564 Ok(true)
565 );
566 }
567
568 #[tokio::test]
569 async fn a_corrupt_stored_hash_refuses_the_login_rather_than_erroring() {
570 let db = db().await;
571 AdminUser::create("alice", "not-a-valid-encoded-hash", &db)
572 .await
573 .unwrap();
574 assert!(matches!(
577 authenticate("alice", "anything", db).await.unwrap(),
578 AuthOutcome::WrongPassword(_)
579 ));
580 }
581
582 #[test]
583 fn every_user_error_renders() {
584 assert!(
585 UserError::Database(sqlx::Error::RowNotFound)
586 .to_string()
587 .starts_with("database error:")
588 );
589 assert_eq!(
590 UserError::Policy("too short".to_string()).to_string(),
591 "too short"
592 );
593 assert_eq!(
594 UserError::DuplicateUsername("bob".to_string()).to_string(),
595 "an admin user named `bob` already exists"
596 );
597 }
598}