use std::io::BufRead;
use std::sync::Arc;
use tracing::{info, warn};
use crate::admin::ops::DeleteOutcome;
use crate::admin::password::{self, PasswordContext};
use crate::admin::prompt::confirm;
use crate::sqlite::admin_session::AdminSession;
use crate::sqlite::admin_user::AdminUser;
use crate::sqlite::db::Database;
#[derive(Debug, thiserror::Error)]
pub enum UserError {
#[error("database error: {0}")]
Database(sqlx::Error),
#[error("{0}")]
Policy(String),
#[error("an admin user named `{0}` already exists")]
DuplicateUsername(String),
}
impl From<sqlx::Error> for UserError {
fn from(error: sqlx::Error) -> Self {
Self::Database(error)
}
}
#[derive(Debug)]
pub enum AuthOutcome {
Authenticated(Box<AdminUser>),
UnknownUser,
WrongPassword(Box<AdminUser>),
Disabled(Box<AdminUser>),
}
pub async fn create_user(
username: &str,
plaintext: &str,
context: &PasswordContext,
database: Arc<Database>,
) -> Result<AdminUser, UserError> {
password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
let normalized = username.trim().to_lowercase();
if normalized.is_empty() {
return Err(UserError::Policy("username must not be empty".to_string()));
}
if AdminUser::find_by_username(&normalized, &database)
.await?
.is_some()
{
return Err(UserError::DuplicateUsername(normalized));
}
let hash = password::hash_password(plaintext);
Ok(AdminUser::create(&normalized, &hash, &database).await?)
}
pub async fn list_users(
limit: i64,
offset: i64,
database: Arc<Database>,
) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
AdminUser::search(limit, offset, &database).await
}
pub async fn set_password(
username: &str,
plaintext: &str,
context: &PasswordContext,
database: Arc<Database>,
) -> Result<Option<AdminUser>, UserError> {
password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(None);
};
let hash = password::hash_password(plaintext);
user.set_password_hash(&hash, &database).await?;
AdminSession::delete_for_user(user.id, &database).await?;
Ok(Some(user))
}
pub async fn set_status(
username: &str,
status: &str,
database: Arc<Database>,
) -> Result<Option<AdminUser>, sqlx::Error> {
let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(None);
};
user.set_status(status, &database).await?;
if status == "disabled" {
AdminSession::delete_for_user(user.id, &database).await?;
}
Ok(Some(user))
}
pub async fn revoke_sessions(
username: &str,
database: Arc<Database>,
) -> Result<Option<u64>, sqlx::Error> {
let Some(user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(None);
};
Ok(Some(
AdminSession::delete_for_user(user.id, &database).await?,
))
}
pub async fn delete_user(username: &str, database: Arc<Database>) -> Result<bool, sqlx::Error> {
let Some(user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(false);
};
AdminUser::delete(user.id, &database).await
}
pub async fn confirm_delete_user(
username: &str,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<DeleteOutcome, sqlx::Error> {
let Some(user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(DeleteOutcome::NotFound);
};
let sessions = AdminSession::list_all(Some(user.id), &database)
.await?
.len();
let prompt = format!(
"Delete admin user {} (status: {}, {sessions} session(s) will cascade)?",
user.username, user.status
);
if !confirm(&prompt, assume_yes, reader) {
return Ok(DeleteOutcome::Cancelled);
}
AdminUser::delete(user.id, &database).await?;
Ok(DeleteOutcome::Deleted)
}
pub async fn authenticate(
username: &str,
plaintext: &str,
database: Arc<Database>,
) -> Result<AuthOutcome, sqlx::Error> {
let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
let _ = password::verify_password(password::dummy_hash(), plaintext);
return Ok(AuthOutcome::UnknownUser);
};
let verified = match password::verify_password(&user.password_hash, plaintext) {
Ok(verified) => verified,
Err(error) => {
warn!(event = "admin_password_hash_unreadable",
outcome = "failure",
user_id = %user.id,
username = %user.username,
error = %error,
"stored password hash could not be decoded; \
run `acme-proxy admin user passwd` to rewrite it");
return Ok(AuthOutcome::WrongPassword(Box::new(user)));
}
};
if !verified {
return Ok(AuthOutcome::WrongPassword(Box::new(user)));
}
if !user.is_active() {
return Ok(AuthOutcome::Disabled(Box::new(user)));
}
if password::needs_rehash(&user.password_hash) {
let rehashed = password::hash_password(plaintext);
user.set_password_hash(&rehashed, &database).await?;
info!(event = "admin_password_rehashed", outcome = "success", user_id = %user.id);
}
Ok(AuthOutcome::Authenticated(Box::new(user)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sqlite::admin_session::NewSession;
const GOOD: &str = "a-long-enough-password";
async fn db() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
async fn user_with_cheap_password(
username: &str,
plaintext: &str,
database: Arc<Database>,
) -> AdminUser {
let salt = [3u8; 16];
let mut digest = [0u8; 32];
ring::pbkdf2::derive(
ring::pbkdf2::PBKDF2_HMAC_SHA256,
std::num::NonZeroU32::new(1).unwrap(),
&salt,
plaintext.as_bytes(),
&mut digest,
);
let encoded = format!(
"pbkdf2-sha256$1${}${}",
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, salt),
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, digest),
);
AdminUser::create(username, &encoded, &database)
.await
.unwrap()
}
#[tokio::test]
async fn create_user_normalizes_and_hashes() {
let db = db().await;
let user = create_user(" Alice ", GOOD, &PasswordContext::empty(), db.clone())
.await
.unwrap();
assert_eq!(user.username, "alice");
assert!(user.is_active());
assert!(!user.password_hash.contains(GOOD));
assert_eq!(
password::verify_password(&user.password_hash, GOOD),
Ok(true)
);
}
#[tokio::test]
async fn create_user_refuses_a_password_below_the_policy() {
let db = db().await;
let error = create_user("alice", "short", &PasswordContext::empty(), db.clone())
.await
.unwrap_err();
assert!(matches!(error, UserError::Policy(_)));
assert!(error.to_string().contains("at least 12"));
assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
}
#[tokio::test]
async fn create_user_refuses_a_common_password_and_a_deployment_word() {
let db = db().await;
let error = create_user(
"alice",
"passwordpassword",
&PasswordContext::empty(),
db.clone(),
)
.await
.unwrap_err();
assert!(matches!(error, UserError::Policy(_)));
assert!(error.to_string().contains("commonly used"));
let mut config = crate::config::Config::default();
config.server.base_url = "https://ca.example.com".to_string();
let context = PasswordContext::from_config(&config, "alice");
let error = create_user("alice", "acmeproxy2026!!", &context, db.clone())
.await
.unwrap_err();
assert!(matches!(error, UserError::Policy(_)));
assert!(error.to_string().contains("names this deployment"));
assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
}
#[tokio::test]
async fn set_password_refuses_a_common_password_before_looking_the_user_up() {
let db = db().await;
let error = set_password(
"nobody",
"passwordpassword",
&PasswordContext::empty(),
db.clone(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("commonly used"), "got: {error}");
}
#[tokio::test]
async fn create_user_refuses_an_empty_username() {
let db = db().await;
let error = create_user(" ", GOOD, &PasswordContext::empty(), db)
.await
.unwrap_err();
assert!(error.to_string().contains("username must not be empty"));
}
#[tokio::test]
async fn create_user_refuses_a_duplicate_in_words_not_a_unique_violation() {
let db = db().await;
create_user("alice", GOOD, &PasswordContext::empty(), db.clone())
.await
.unwrap();
let error = create_user("ALICE", GOOD, &PasswordContext::empty(), db)
.await
.unwrap_err();
assert!(matches!(error, UserError::DuplicateUsername(_)));
assert_eq!(
error.to_string(),
"an admin user named `alice` already exists"
);
}
#[tokio::test]
async fn set_password_revokes_every_session_of_that_user() {
let db = db().await;
let user = user_with_cheap_password("alice", "old-password", db.clone()).await;
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "hash-a",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
assert!(
set_password("alice", GOOD, &PasswordContext::empty(), db.clone())
.await
.unwrap()
.is_some()
);
assert!(
AdminSession::list_all(Some(user.id), &db)
.await
.unwrap()
.is_empty(),
"a password change that left sessions alive would be a change in name only"
);
let reloaded = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert_eq!(
password::verify_password(&reloaded.password_hash, GOOD),
Ok(true)
);
}
#[tokio::test]
async fn set_password_of_an_unknown_user_is_none_and_checks_the_policy_first() {
let db = db().await;
assert!(
set_password("nobody", GOOD, &PasswordContext::empty(), db.clone())
.await
.unwrap()
.is_none()
);
assert!(matches!(
set_password("nobody", "short", &PasswordContext::empty(), db)
.await
.unwrap_err(),
UserError::Policy(_)
));
}
#[tokio::test]
async fn disabling_a_user_drops_their_sessions_but_enabling_does_not() {
let db = db().await;
let user = user_with_cheap_password("alice", "pw", db.clone()).await;
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "hash-a",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
set_status("alice", "disabled", db.clone()).await.unwrap();
assert!(
AdminSession::list_all(Some(user.id), &db)
.await
.unwrap()
.is_empty()
);
let reenabled = set_status("alice", "active", db.clone())
.await
.unwrap()
.unwrap();
assert!(reenabled.is_active());
assert!(
set_status("nobody", "disabled", db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn revoke_sessions_counts_what_it_removed() {
let db = db().await;
let user = user_with_cheap_password("alice", "pw", db.clone()).await;
for hash in ["a", "b"] {
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: hash,
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
}
assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(2));
assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(0));
assert_eq!(revoke_sessions("nobody", db).await.unwrap(), None);
}
#[tokio::test]
async fn delete_user_removes_the_row_and_reports_whether_it_existed() {
let db = db().await;
user_with_cheap_password("alice", "pw", db.clone()).await;
assert!(delete_user("alice", db.clone()).await.unwrap());
assert!(!delete_user("alice", db).await.unwrap());
}
#[tokio::test]
async fn confirm_delete_user_covers_its_three_outcomes() {
let db = db().await;
let mut empty: &[u8] = &[];
assert_eq!(
confirm_delete_user("nobody", true, &mut empty, db.clone())
.await
.unwrap(),
DeleteOutcome::NotFound
);
user_with_cheap_password("alice", "pw", db.clone()).await;
let mut no = b"n\n".as_slice();
assert_eq!(
confirm_delete_user("alice", false, &mut no, db.clone())
.await
.unwrap(),
DeleteOutcome::Cancelled
);
assert!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.is_some()
);
let mut empty: &[u8] = &[];
assert_eq!(
confirm_delete_user("alice", true, &mut empty, db.clone())
.await
.unwrap(),
DeleteOutcome::Deleted
);
assert!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn authenticate_accepts_the_right_password() {
let db = db().await;
user_with_cheap_password("alice", "the-password", db.clone()).await;
let outcome = authenticate("alice", "the-password", db).await.unwrap();
assert!(matches!(outcome, AuthOutcome::Authenticated(_)));
}
#[tokio::test]
async fn authenticate_distinguishes_its_failures_for_the_log() {
let db = db().await;
user_with_cheap_password("alice", "the-password", db.clone()).await;
assert!(matches!(
authenticate("alice", "wrong", db.clone()).await.unwrap(),
AuthOutcome::WrongPassword(_)
));
assert!(matches!(
authenticate("nobody", "the-password", db.clone())
.await
.unwrap(),
AuthOutcome::UnknownUser
));
set_status("alice", "disabled", db.clone()).await.unwrap();
assert!(matches!(
authenticate("alice", "the-password", db).await.unwrap(),
AuthOutcome::Disabled(_)
));
}
#[tokio::test]
async fn authenticate_is_case_insensitive_in_the_username() {
let db = db().await;
user_with_cheap_password("alice", "the-password", db.clone()).await;
assert!(matches!(
authenticate("ALICE", "the-password", db).await.unwrap(),
AuthOutcome::Authenticated(_)
));
}
#[tokio::test]
async fn a_successful_login_rehashes_a_row_written_under_older_parameters() {
let db = db().await;
let before = user_with_cheap_password("alice", "the-password", db.clone()).await;
assert!(password::needs_rehash(&before.password_hash));
let outcome = authenticate("alice", "the-password", db.clone())
.await
.unwrap();
let AuthOutcome::Authenticated(user) = outcome else {
panic!("expected a successful authentication");
};
assert!(!password::needs_rehash(&user.password_hash));
let reloaded = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert!(!password::needs_rehash(&reloaded.password_hash));
assert_eq!(
password::verify_password(&reloaded.password_hash, "the-password"),
Ok(true)
);
}
#[tokio::test]
async fn a_corrupt_stored_hash_refuses_the_login_rather_than_erroring() {
let db = db().await;
AdminUser::create("alice", "not-a-valid-encoded-hash", &db)
.await
.unwrap();
assert!(matches!(
authenticate("alice", "anything", db).await.unwrap(),
AuthOutcome::WrongPassword(_)
));
}
#[test]
fn every_user_error_renders() {
assert!(
UserError::Database(sqlx::Error::RowNotFound)
.to_string()
.starts_with("database error:")
);
assert_eq!(
UserError::Policy("too short".to_string()).to_string(),
"too short"
);
assert_eq!(
UserError::DuplicateUsername("bob".to_string()).to_string(),
"an admin user named `bob` already exists"
);
}
}