//! Operator authentication — Sonarr/Radarr-style single-admin Forms model. //! //! On a fresh install, no admin account exists; the WebUI's first-run //! flow prompts the operator to create one. After that the chosen //! credentials gate `/api/*` access. The admin can rotate username + //! password from Settings → Account. //! //! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked //! for "you have access or you don't". When SSO is configured, additional //! users come in through the IdP; the locally-stored admin is the //! fallback owner who can change SSO config or the seal-breaker for an //! IdP outage. So one record is enough. //! //! Storage policy mirrors [`crate::host_bindings::HostBindings`] and //! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the //! crash-survival cache; a corrupt `auth.json` falls back to "no admin //! configured" rather than blocking startup, which puts the UI back //! into setup mode rather than locking the operator out. use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; use time::OffsetDateTime; use crate::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdminAccount { pub username: String, /// bcrypt hash (cost 10). The plaintext password never leaves the /// request that set it — same discipline as the per-ISO boot password. pub password_hash: String, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub updated_at: OffsetDateTime, } /// Public projection — no hash, safe to ship to the WebUI. #[derive(Debug, Clone, Serialize)] pub struct AdminPublic { pub username: String, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub updated_at: OffsetDateTime, } impl From<&AdminAccount> for AdminPublic { fn from(a: &AdminAccount) -> Self { Self { username: a.username.clone(), created_at: a.created_at, updated_at: a.updated_at, } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct Inner { admin: Option, } /// In-memory + on-disk admin registry. Cheap to clone. #[derive(Debug, Clone)] pub struct AdminStore { path: Arc, inner: Arc>, } impl AdminStore { /// Load from `/auth.json`, or start empty. A bad file /// logs a warning and falls back to "no admin configured" — better /// to surface the setup flow than lock the operator out of their /// own install. #[must_use] pub fn load_or_default(work_dir: &std::path::Path) -> Self { let path = work_dir.join("auth.json"); let inner = match std::fs::read_to_string(&path) { Ok(text) => match serde_json::from_str::(&text) { Ok(parsed) => parsed, Err(e) => { tracing::warn!( target: "openpxe::auth", "auth.json present but unreadable ({e}); starting in setup mode" ); Inner::default() } }, Err(_) => Inner::default(), }; Self { path: Arc::new(path), inner: Arc::new(RwLock::new(inner)), } } /// Has an admin been bootstrapped? Drives the first-run / login /// fork in the HTTP layer. #[must_use] pub fn is_configured(&self) -> bool { self.inner.read().admin.is_some() } /// Public-safe snapshot for the WebUI. #[must_use] pub fn snapshot(&self) -> Option { self.inner.read().admin.as_ref().map(AdminPublic::from) } /// First-run setup: create the admin account. Fails if one already /// exists — the HTTP layer surfaces that as 409. pub fn bootstrap(&self, username: &str, password: &str) -> Result { validate_username(username)?; validate_password(password)?; let hash = bcrypt_hash(password)?; let now = OffsetDateTime::now_utc(); let admin = AdminAccount { username: username.trim().to_string(), password_hash: hash, created_at: now, updated_at: now, }; { let mut g = self.inner.write(); if g.admin.is_some() { return Err(Error::Invalid( "admin account already configured".into(), )); } g.admin = Some(admin.clone()); } self.persist(); tracing::info!( target: "openpxe::auth", username = %admin.username, "admin account created (first-run setup)" ); Ok((&admin).into()) } /// Verify credentials. Returns the admin record (public projection) /// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt /// failure (treated as "auth not available right now" by callers). pub fn verify(&self, username: &str, password: &str) -> Result> { let Some(admin) = self.inner.read().admin.clone() else { return Ok(None); }; if username.trim() != admin.username { return Ok(None); } // bcrypt compares in constant time relative to the same hash. // Doing the username check first is fine — a username mismatch // returns immediately, but the only thing leaked is "this isn't // the admin's username" which the operator already knows. match bcrypt::verify(password, &admin.password_hash) { Ok(true) => Ok(Some((&admin).into())), Ok(false) => Ok(None), Err(e) => Err(Error::Other(e.into())), } } /// Rotate username and/or password. `current_password` must match /// the *existing* hash — same flow as Sonarr's "current password /// required to change". `new_username`/`new_password` are optional: /// pass only what you want to change. pub fn update_credentials( &self, current_password: &str, new_username: Option<&str>, new_password: Option<&str>, ) -> Result { // Re-check ownership before any state mutation. let existing = self .inner .read() .admin .clone() .ok_or_else(|| Error::Invalid("no admin configured".into()))?; match bcrypt::verify(current_password, &existing.password_hash) { Ok(true) => {} Ok(false) => return Err(Error::Invalid("current password is incorrect".into())), Err(e) => return Err(Error::Other(e.into())), } let mut updated = existing.clone(); if let Some(u) = new_username { validate_username(u)?; updated.username = u.trim().to_string(); } if let Some(p) = new_password { validate_password(p)?; updated.password_hash = bcrypt_hash(p)?; } updated.updated_at = OffsetDateTime::now_utc(); { let mut g = self.inner.write(); g.admin = Some(updated.clone()); } self.persist(); tracing::info!( target: "openpxe::auth", username = %updated.username, "admin credentials updated" ); Ok((&updated).into()) } fn persist(&self) { let snap = self.inner.read().clone(); let body = match serde_json::to_vec_pretty(&snap) { Ok(b) => b, Err(e) => { tracing::warn!(target: "openpxe::auth", "serialize auth.json: {e}"); return; } }; if let Some(parent) = self.path.parent() { let _ = std::fs::create_dir_all(parent); } let tmp = self.path.with_extension("json.tmp"); if let Err(e) = std::fs::write(&tmp, body) { tracing::warn!(target: "openpxe::auth", "write auth.json tmp: {e}"); return; } if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}"); } } } fn validate_username(u: &str) -> Result<()> { let u = u.trim(); if u.is_empty() { return Err(Error::Invalid("username must not be empty".into())); } if u.len() > 64 { return Err(Error::Invalid("username must be 64 chars or fewer".into())); } if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') { return Err(Error::Invalid( "username must be ASCII printable with no ':' character".into(), )); } Ok(()) } fn validate_password(p: &str) -> Result<()> { if p.len() < 8 { return Err(Error::Invalid( "password must be at least 8 characters".into(), )); } if p.len() > 256 { return Err(Error::Invalid( "password must be 256 characters or fewer".into(), )); } Ok(()) } fn bcrypt_hash(password: &str) -> Result { bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into())) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn empty_after_load_when_no_file() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); assert!(!s.is_configured()); assert!(s.snapshot().is_none()); } #[test] fn bootstrap_then_verify_round_trip() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap(); assert_eq!(pub_.username, "admin"); assert!(s.is_configured()); // Correct creds match; wrong creds don't. assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some()); assert!(s.verify("admin", "wrong").unwrap().is_none()); assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none()); } #[test] fn bootstrap_rejects_second_call() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); s.bootstrap("admin", "hunter2hunter2").unwrap(); let r = s.bootstrap("other", "anotherpass1"); assert!(matches!(r, Err(Error::Invalid(_)))); } #[test] fn round_trip_survives_disk_reload() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); s.bootstrap("admin", "hunter2hunter2").unwrap(); drop(s); let s2 = AdminStore::load_or_default(dir.path()); assert!(s2.is_configured()); assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some()); } #[test] fn update_credentials_requires_current_password() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); s.bootstrap("admin", "hunter2hunter2").unwrap(); // Wrong current password → no change. let r = s.update_credentials("nope", None, Some("newpassword1")); assert!(matches!(r, Err(Error::Invalid(_)))); assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some()); // Correct current password rotates only what's supplied. s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1")) .unwrap(); assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none()); assert!(s.verify("alice", "newpassword1").unwrap().is_some()); } #[test] fn update_credentials_partial_password_only_keeps_username() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); s.bootstrap("admin", "hunter2hunter2").unwrap(); s.update_credentials("hunter2hunter2", None, Some("newpassword1")) .unwrap(); assert!(s.verify("admin", "newpassword1").unwrap().is_some()); } #[test] fn validates_username_and_password() { let dir = tempdir().unwrap(); let s = AdminStore::load_or_default(dir.path()); assert!(s.bootstrap("", "hunter2hunter2").is_err()); assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved assert!(s.bootstrap("admin", "short").is_err()); // <8 chars // 65-char username is too long. let long = "a".repeat(65); assert!(s.bootstrap(&long, "hunter2hunter2").is_err()); } }