use std::io::BufRead;
use std::sync::Arc;
use tracing::{info, warn};
use crate::admin::password::{self, PasswordContext};
use crate::admin::prompt::confirm;
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminRole;
use acme_proxy_store::admin_user::AdminStatus;
use acme_proxy_store::admin_user::AdminUser;
use acme_proxy_store::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),
#[error("{0}")]
InvalidContact(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>),
}
#[must_use]
pub fn valid_username(username: &str) -> bool {
!username.is_empty()
&& username
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
}
pub async fn create_user(
username: &str,
plaintext: &str,
context: &PasswordContext,
role: Option<AdminRole>,
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 !valid_username(&normalized) {
return Err(UserError::Policy(format!(
"invalid username `{normalized}`: use lowercase letters, digits, `-`, `_` and `.` \
(the name is a URL segment on the web admin)"
)));
}
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, role, &database).await?)
}
pub async fn set_role(
username: &str,
role: AdminRole,
database: Arc<Database>,
) -> Result<Option<(AdminUser, u64)>, UserError> {
let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(None);
};
if user.role() == AdminRole::Admin && role != AdminRole::Admin {
let admins = AdminUser::list_all(&database)
.await?
.iter()
.filter(|other| other.role() == AdminRole::Admin)
.count();
if admins <= 1 {
return Err(UserError::Policy(format!(
"`{}` is the only admin: demoting them would leave the panel with no one who \
can manage operators. Promote somebody else first.",
user.username
)));
}
}
user.set_role(role, &database).await?;
let revoked = AdminSession::delete_for_user(user.id, &database).await?;
Ok(Some((user, revoked)))
}
pub async fn set_contact_email(
username: &str,
contact: Option<&str>,
database: Arc<Database>,
) -> Result<Option<AdminUser>, UserError> {
let trimmed = contact.map(str::trim).filter(|value| !value.is_empty());
if let Some(address) = trimmed {
address
.parse::<lettre::message::Mailbox>()
.map_err(|error| {
UserError::InvalidContact(format!(
"`{address}` is not a valid email address: {error}"
))
})?;
}
let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(None);
};
user.set_contact_email(trimmed, &database).await?;
Ok(Some(user))
}
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, u64)>, 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?;
let revoked = AdminSession::delete_for_user(user.id, &database).await?;
Ok(Some((user, revoked)))
}
pub async fn change_own_password(
user: &mut AdminUser,
new_password: &str,
context: &PasswordContext,
keep_session: &str,
database: Arc<Database>,
) -> Result<(), UserError> {
password::check_password_policy(new_password, context).map_err(UserError::Policy)?;
let hash = password::hash_password(new_password);
user.set_password_hash(&hash, &database).await?;
AdminSession::delete_for_user_except(user.id, keep_session, &database).await?;
Ok(())
}
pub async fn set_status(
username: &str,
status: AdminStatus,
database: Arc<Database>,
) -> Result<Option<(AdminUser, u64)>, sqlx::Error> {
let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(None);
};
user.set_status(status.as_str(), &database).await?;
let revoked = if status == AdminStatus::Disabled {
AdminSession::delete_for_user(user.id, &database).await?
} else {
0
};
Ok(Some((user, revoked)))
}
pub async fn operators_without_a_contact(database: Arc<Database>) -> Result<usize, sqlx::Error> {
Ok(AdminUser::list_all(&database)
.await?
.iter()
.filter(|user| user.contact_email.is_none())
.count())
}
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
}
#[derive(Debug, PartialEq, Eq)]
pub enum UserDeleteOutcome {
NotFound,
Cancelled,
Deleted(crate::admin::ops::Deleted),
}
pub async fn confirm_delete_user(
username: &str,
assume_yes: bool,
reader: &mut impl BufRead,
database: Arc<Database>,
) -> Result<UserDeleteOutcome, sqlx::Error> {
let Some(user) = AdminUser::find_by_username(username, &database).await? else {
return Ok(UserDeleteOutcome::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(UserDeleteOutcome::Cancelled);
}
AdminUser::delete(user.id, &database).await?;
Ok(UserDeleteOutcome::Deleted(crate::admin::ops::Deleted {
cascaded: sessions as u64,
}))
}
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_off_runtime(password::dummy_hash(), plaintext).await;
return Ok(AuthOutcome::UnknownUser);
};
let verified = match password::verify_password_off_runtime(&user.password_hash, plaintext).await
{
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 Some(rehashed) = password::hash_password_off_runtime(plaintext).await
{
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 acme_proxy_store::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, None, &database)
.await
.unwrap()
}
#[tokio::test]
async fn set_contact_email_stores_clears_and_validates() {
let db = db().await;
create_user("alice", GOOD, &PasswordContext::empty(), None, db.clone())
.await
.unwrap();
assert!(
set_contact_email("nobody", Some("x@example.com"), db.clone())
.await
.unwrap()
.is_none()
);
let error = set_contact_email("alice", Some("not an address"), db.clone())
.await
.unwrap_err();
assert!(matches!(error, UserError::InvalidContact(_)), "{error}");
let user = set_contact_email("alice", Some(" alice@example.com "), db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
for cleared in [Some(" "), None] {
let user = set_contact_email("alice", cleared, db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(user.contact_email, None);
set_contact_email("alice", Some("alice@example.com"), db.clone())
.await
.unwrap();
}
}
#[tokio::test]
async fn create_user_normalizes_and_hashes() {
let db = db().await;
let user = create_user(
" Alice ",
GOOD,
&PasswordContext::empty(),
None,
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(),
None,
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(),
None,
db.clone(),
)
.await
.unwrap_err();
assert!(matches!(error, UserError::Policy(_)));
assert!(error.to_string().contains("commonly used"));
let mut config = acme_proxy_core::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, None, 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(), None, 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(), None, db.clone())
.await
.unwrap();
let error = create_user("ALICE", GOOD, &PasswordContext::empty(), None, 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 change_own_password_keeps_the_calling_session_and_drops_every_other() {
let db = db().await;
let mut user = user_with_cheap_password("alice", "old-password", db.clone()).await;
let kept = AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "kept-hash",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(3600),
&db,
)
.await
.unwrap();
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "other-hash",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(3600),
&db,
)
.await
.unwrap();
change_own_password(
&mut user,
GOOD,
&PasswordContext::empty(),
&kept.token_hash,
db.clone(),
)
.await
.unwrap();
let live = AdminSession::list_all(Some(user.id), &db).await.unwrap();
assert_eq!(live.len(), 1, "every other session must be revoked");
assert_eq!(live[0].token_hash, "kept-hash");
let reloaded = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert_eq!(
password::verify_password(&reloaded.password_hash, GOOD),
Ok(true)
);
assert_eq!(
password::verify_password(&reloaded.password_hash, "old-password"),
Ok(false)
);
}
#[tokio::test]
async fn change_own_password_checks_the_policy() {
let db = db().await;
let mut user = user_with_cheap_password("alice", "old-password", db.clone()).await;
let before = user.password_hash.clone();
let error = change_own_password(
&mut user,
"short",
&PasswordContext::empty(),
"kept-hash",
db.clone(),
)
.await
.unwrap_err();
assert!(matches!(error, UserError::Policy(_)));
let reloaded = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.password_hash, before);
}
#[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", AdminStatus::Disabled, db.clone())
.await
.unwrap();
assert!(
AdminSession::list_all(Some(user.id), &db)
.await
.unwrap()
.is_empty()
);
let reenabled = set_status("alice", AdminStatus::Active, db.clone())
.await
.unwrap()
.unwrap();
assert!(reenabled.0.is_active());
assert!(
set_status("nobody", AdminStatus::Disabled, db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn set_role_changes_the_tier_and_revokes_every_session() {
let db = db().await;
let user = user_with_cheap_password("alice", "pw", db.clone()).await;
assert_eq!(user.role(), AdminRole::Admin, "a fresh row reads as admin");
user_with_cheap_password("root", "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();
let updated = set_role("alice", AdminRole::Viewer, db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(updated.0.role(), AdminRole::Viewer);
assert!(
AdminSession::list_all(Some(user.id), &db)
.await
.unwrap()
.is_empty(),
"a demotion that left an admin session live would take effect only on expiry"
);
assert!(
set_role("nobody", AdminRole::Operator, db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn demoting_the_last_admin_is_refused() {
let db = db().await;
user_with_cheap_password("alice", "pw", db.clone()).await;
let error = set_role("alice", AdminRole::Viewer, db.clone())
.await
.expect_err("the only admin cannot be demoted");
assert!(error.to_string().contains("only admin"), "{error}");
assert_eq!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap()
.role(),
AdminRole::Admin
);
user_with_cheap_password("root", "pw", db.clone()).await;
let (updated, _) = set_role("alice", AdminRole::Viewer, db.clone())
.await
.unwrap()
.unwrap();
assert_eq!(updated.role(), AdminRole::Viewer);
assert!(set_role("alice", AdminRole::Admin, db).await.is_ok());
}
#[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(),
UserDeleteOutcome::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(),
UserDeleteOutcome::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(),
UserDeleteOutcome::Deleted(crate::admin::ops::Deleted { cascaded: 0 })
);
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", AdminStatus::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", None, &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"
);
}
}