use std::fmt; use std::str::FromStr; use jiff::Timestamp; use rand::RngExt as _; use sqlx::Row; use crate::db::{Db, DbError, fmt_ts, parse_ts}; pub const MIN_PASSWORD_LEN: usize = 12; pub const MAX_PASSWORD_LEN: usize = 1024; const TEMPORARY_PASSWORD_LEN: usize = 20; const TEMPORARY_PASSWORD_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Role { User, Admin, } impl Role { pub fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Admin => "admin", } } } impl fmt::Display for Role { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl FromStr for Role { type Err = String; fn from_str(value: &str) -> Result { match value { "user" => Ok(Self::User), "admin" => Ok(Self::Admin), _ => Err(format!("invalid role {value:?}; expected user or admin")), } } } #[derive(Clone)] pub struct User { pub id: i64, pub username: String, pub password_hash: String, pub role: Role, pub disabled: bool, /// Whether protected routes must redirect this user to change their password. pub must_change_password: bool, pub created_at: Timestamp, pub last_login_at: Option, } impl fmt::Debug for User { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("User") .field("id", &self.id) .field("username", &self.username) .field("password_hash", &"[REDACTED]") .field("role", &self.role) .field("disabled", &self.disabled) .field("must_change_password", &self.must_change_password) .field("created_at", &self.created_at) .field("last_login_at", &self.last_login_at) .finish() } } #[derive(Debug, Clone)] pub struct UserListRow { pub user: User, pub open_sessions: i64, } pub fn validate_username(username: &str) -> anyhow::Result<()> { if username.is_empty() || username.len() > 32 || !username .bytes() .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')) { anyhow::bail!("username must be 1-32 characters from A-Z, a-z, 0-9, '.', '_' or '-'"); } Ok(()) } pub fn validate_password(password: &str) -> anyhow::Result<()> { if password.len() < MIN_PASSWORD_LEN { anyhow::bail!("password must be at least {MIN_PASSWORD_LEN} characters"); } if password.len() > MAX_PASSWORD_LEN { anyhow::bail!("password must be at most {MAX_PASSWORD_LEN} characters"); } Ok(()) } pub fn hash_password(plain: &str) -> String { password_auth::generate_hash(plain) } pub fn verify_password(hash: &str, plain: &str) -> bool { password_auth::verify_password(plain, hash).is_ok() } pub async fn find_by_username(db: &Db, username: &str) -> Result, DbError> { let row = sqlx::query( "SELECT id, username, password_hash, role, disabled, must_change_password, created_at, last_login_at FROM users WHERE username = ? COLLATE NOCASE", ) .bind(username) .fetch_optional(db.pool()) .await?; row.as_ref().map(user_from_row).transpose() } pub async fn find_by_id(db: &Db, id: i64) -> Result, DbError> { let row = sqlx::query( "SELECT id, username, password_hash, role, disabled, must_change_password, created_at, last_login_at FROM users WHERE id = ?", ) .bind(id) .fetch_optional(db.pool()) .await?; row.as_ref().map(user_from_row).transpose() } pub async fn add(db: &Db, username: &str, password: &str, admin: bool) -> anyhow::Result { add_user(db, username, password, admin, false).await } /// Create a reader account with a random password that must be changed at first sign-in. pub async fn add_with_temporary_password( db: &Db, username: &str, ) -> anyhow::Result<(User, String)> { let password = temporary_password(); let user = add_user(db, username, &password, false, true).await?; Ok((user, password)) } async fn add_user( db: &Db, username: &str, password: &str, admin: bool, must_change_password: bool, ) -> anyhow::Result { validate_username(username)?; validate_password(password)?; if find_by_username(db, username).await?.is_some() { anyhow::bail!("user {username:?} already exists"); } let hash = hash_password(password); let created_at = Timestamp::now(); let role = if admin { Role::Admin } else { Role::User }; let id: i64 = sqlx::query_scalar( "INSERT INTO users (username, password_hash, role, must_change_password, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id", ) .bind(username) .bind(&hash) .bind(role.as_str()) .bind(must_change_password) .bind(fmt_ts(created_at)) .fetch_one(db.pool()) .await?; Ok(User { id, username: username.to_string(), password_hash: hash, role, disabled: false, must_change_password, created_at, last_login_at: None, }) } pub async fn passwd(db: &Db, username: &str, password: &str) -> anyhow::Result { validate_password(password)?; let hash = hash_password(password); let result = sqlx::query( "UPDATE users SET password_hash = ?, must_change_password = 0 WHERE username = ? COLLATE NOCASE", ) .bind(hash) .bind(username) .execute(db.pool()) .await?; require_one(username, result.rows_affected())?; logout(db, username).await } pub async fn set_role(db: &Db, username: &str, role: Role) -> anyhow::Result<()> { let result = sqlx::query("UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE") .bind(role.as_str()) .bind(username) .execute(db.pool()) .await?; require_one(username, result.rows_affected()) } pub async fn set_disabled(db: &Db, username: &str, disabled: bool) -> anyhow::Result { let result = sqlx::query("UPDATE users SET disabled = ? WHERE username = ? COLLATE NOCASE") .bind(disabled) .bind(username) .execute(db.pool()) .await?; require_one(username, result.rows_affected())?; if disabled { logout(db, username).await } else { Ok(0) } } pub async fn logout(db: &Db, username: &str) -> anyhow::Result { let Some(user) = find_by_username(db, username).await? else { anyhow::bail!("user {username:?} was not found"); }; let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?") .bind(user.id) .execute(db.pool()) .await?; Ok(result.rows_affected()) } pub async fn list(db: &Db) -> anyhow::Result> { let rows = sqlx::query( "SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.must_change_password, u.created_at, u.last_login_at, COUNT(s.id) AS open_sessions FROM users u LEFT JOIN sessions s ON s.user_id = u.id AND s.expiry > unixepoch() GROUP BY u.id ORDER BY u.username COLLATE NOCASE", ) .fetch_all(db.pool()) .await?; rows.iter() .map(|row| { Ok(UserListRow { user: user_from_row(row)?, open_sessions: row.get("open_sessions"), }) }) .collect() } fn temporary_password() -> String { let mut rng = rand::rng(); (0..TEMPORARY_PASSWORD_LEN) .map(|_| { let index = rng.random_range(..TEMPORARY_PASSWORD_ALPHABET.len()); char::from(TEMPORARY_PASSWORD_ALPHABET[index]) }) .collect() } fn require_one(username: &str, count: u64) -> anyhow::Result<()> { if count == 0 { anyhow::bail!("user {username:?} was not found"); } Ok(()) } fn user_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { let role_raw: String = row.get("role"); let role = role_raw.parse().map_err(|_| DbError::Decode { column: "users.role", value: role_raw, })?; Ok(User { id: row.get("id"), username: row.get("username"), password_hash: row.get("password_hash"), role, disabled: row.get("disabled"), must_change_password: row.get("must_change_password"), created_at: parse_ts("users.created_at", &row.get::("created_at"))?, last_login_at: row .get::, _>("last_login_at") .map(|value| parse_ts("users.last_login_at", &value)) .transpose()?, }) } #[cfg(test)] mod tests { use super::*; #[test] fn password_hashes_round_trip_and_fail_safely() { let hash = hash_password("correct horse battery"); assert!(verify_password(&hash, "correct horse battery")); assert!(!verify_password(&hash, "wrong password")); assert!(!verify_password("not a phc string", "anything")); assert!( !format!( "{:?}", User { id: 1, username: "reader".into(), password_hash: hash.clone(), role: Role::User, disabled: false, must_change_password: false, created_at: Timestamp::now(), last_login_at: None, } ) .contains(&hash) ); } #[tokio::test] async fn temporary_password_is_valid_and_passwd_clears_the_change_flag() { let dir = tempfile::tempdir().unwrap(); let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) .await .unwrap(); let (user, password) = add_with_temporary_password(&db, "reader").await.unwrap(); assert_eq!(password.len(), TEMPORARY_PASSWORD_LEN); assert!( password .bytes() .all(|byte| TEMPORARY_PASSWORD_ALPHABET.contains(&byte)) ); validate_password(&password).unwrap(); assert!(verify_password(&user.password_hash, &password)); assert!(user.must_change_password); passwd(&db, "reader", "a final operator password") .await .unwrap(); assert!( !find_by_username(&db, "reader") .await .unwrap() .unwrap() .must_change_password ); } #[tokio::test] async fn user_operations_validate_and_are_case_insensitive() { let dir = tempfile::tempdir().unwrap(); let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) .await .unwrap(); assert!(add(&db, "reader", "too-short", false).await.is_err()); let user = add(&db, "Reader", "correct horse battery", true) .await .unwrap(); assert_eq!(user.role, Role::Admin); assert!(!user.must_change_password); assert!( add(&db, "reader", "another valid password", false) .await .is_err() ); set_role(&db, "READER", Role::User).await.unwrap(); assert_eq!( find_by_username(&db, "reader").await.unwrap().unwrap().role, Role::User ); passwd(&db, "reader", "a replacement password") .await .unwrap(); set_disabled(&db, "reader", true).await.unwrap(); assert!( find_by_username(&db, "reader") .await .unwrap() .unwrap() .disabled ); set_disabled(&db, "reader", false).await.unwrap(); } }