// Authentication module for Archipelago // Handles user setup, onboarding, and login use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tokio::fs; use crate::totp::TotpData; #[derive(Debug, Clone, Serialize, Deserialize)] struct OnboardingState { complete: bool, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { pub password_hash: String, pub setup_complete: bool, pub onboarding_complete: bool, #[serde(default)] pub totp: Option, } #[allow(dead_code)] pub struct AuthManager { data_dir: PathBuf, } #[allow(dead_code)] impl AuthManager { pub fn new(data_dir: PathBuf) -> Self { Self { data_dir } } pub async fn is_setup(&self) -> Result { let user_file = self.data_dir.join("user.json"); Ok(user_file.exists()) } pub async fn get_user(&self) -> Result> { let user_file = self.data_dir.join("user.json"); if !user_file.exists() { return Ok(None); } let content = fs::read_to_string(&user_file).await?; let user: User = serde_json::from_str(&content)?; Ok(Some(user)) } pub async fn setup_user(&self, password: &str) -> Result<()> { use bcrypt::{hash, DEFAULT_COST}; let password_hash = hash(password, DEFAULT_COST)?; // If onboarding was already completed (before setup), preserve that let onboarding_complete = self.is_onboarding_complete().await?; let user = User { password_hash, setup_complete: true, onboarding_complete, totp: None, }; let user_file = self.data_dir.join("user.json"); let content = serde_json::to_string_pretty(&user)?; fs::write(&user_file, content).await?; Ok(()) } pub async fn complete_onboarding(&self) -> Result<()> { // Persist to onboarding.json (works even before user/setup exists) let onboarding_file = self.data_dir.join("onboarding.json"); let state = OnboardingState { complete: true }; fs::write( &onboarding_file, serde_json::to_string_pretty(&state)?, ) .await?; // Also update user.json if it exists (keeps them in sync) if let Some(mut user) = self.get_user().await? { user.onboarding_complete = true; let user_file = self.data_dir.join("user.json"); let content = serde_json::to_string_pretty(&user)?; fs::write(&user_file, content).await?; } Ok(()) } /// Reset onboarding state so the user can go through onboarding again (dev/testing). pub async fn reset_onboarding(&self) -> Result<()> { let onboarding_file = self.data_dir.join("onboarding.json"); let state = OnboardingState { complete: false }; fs::write( &onboarding_file, serde_json::to_string_pretty(&state)?, ) .await?; if let Some(mut user) = self.get_user().await? { user.onboarding_complete = false; let user_file = self.data_dir.join("user.json"); let content = serde_json::to_string_pretty(&user)?; fs::write(&user_file, content).await?; } Ok(()) } pub async fn is_onboarding_complete(&self) -> Result { // Check onboarding.json first (persisted before user setup) let onboarding_file = self.data_dir.join("onboarding.json"); if onboarding_file.exists() { let content = fs::read_to_string(&onboarding_file).await?; if let Ok(state) = serde_json::from_str::(&content) { if state.complete { return Ok(true); } } } // Fallback: user.json Ok(self .get_user() .await? .map(|u| u.onboarding_complete) .unwrap_or(false)) } /// Check if 2FA is enabled for the user. pub async fn is_totp_enabled(&self) -> Result { Ok(self.get_user().await?.map(|u| u.totp.is_some()).unwrap_or(false)) } /// Get the TOTP data (if 2FA is enabled). pub async fn get_totp_data(&self) -> Result> { Ok(self.get_user().await?.and_then(|u| u.totp)) } /// Save TOTP data to user.json (enable 2FA). pub async fn save_totp(&self, totp_data: TotpData) -> Result<()> { let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?; user.totp = Some(totp_data); let user_file = self.data_dir.join("user.json"); fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?; Ok(()) } /// Remove TOTP data from user.json (disable 2FA). pub async fn remove_totp(&self) -> Result<()> { let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?; user.totp = None; let user_file = self.data_dir.join("user.json"); fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?; Ok(()) } /// Update TOTP data in place (e.g. after consuming a backup code or recording a used step). pub async fn update_totp(&self, totp_data: TotpData) -> Result<()> { self.save_totp(totp_data).await } pub async fn verify_password(&self, password: &str) -> Result { use bcrypt::verify; if let Some(user) = self.get_user().await? { Ok(verify(password, &user.password_hash)?) } else { Ok(false) } } /// Change password: verify current, validate new, update user.json and optionally SSH. /// New password must be 12+ chars with upper, lower, digit, and special character. pub async fn change_password( &self, current_password: &str, new_password: &str, also_change_ssh: bool, ) -> Result<()> { use bcrypt::{hash, DEFAULT_COST}; if !self.verify_password(current_password).await? { anyhow::bail!("Current password is incorrect"); } validate_password_strength(new_password)?; let password_hash = hash(new_password, DEFAULT_COST)?; let mut user = self .get_user() .await? .ok_or_else(|| anyhow::anyhow!("User not set up"))?; user.password_hash = password_hash; // Re-encrypt TOTP MEK under new password if 2FA is enabled if let Some(ref totp_data) = user.totp { let rekeyed = crate::totp::rekey(totp_data, current_password, new_password)?; user.totp = Some(rekeyed); } let user_file = self.data_dir.join("user.json"); let content = serde_json::to_string_pretty(&user)?; fs::write(&user_file, content).await?; if also_change_ssh { change_ssh_password(new_password).await?; } Ok(()) } } /// Validate password strength: 12+ chars, upper, lower, digit, special. fn validate_password_strength(password: &str) -> Result<()> { if password.len() < 12 { anyhow::bail!("Password must be at least 12 characters"); } if !password.chars().any(|c| c.is_ascii_uppercase()) { anyhow::bail!("Password must contain at least one uppercase letter"); } if !password.chars().any(|c| c.is_ascii_lowercase()) { anyhow::bail!("Password must contain at least one lowercase letter"); } if !password.chars().any(|c| c.is_ascii_digit()) { anyhow::bail!("Password must contain at least one digit"); } if !password.chars().any(|c| !c.is_ascii_alphanumeric()) { anyhow::bail!("Password must contain at least one special character (!@#$%^&* etc.)"); } Ok(()) } /// Change the archipelago user's SSH/login password. /// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors). /// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH. async fn change_ssh_password(new_password: &str) -> Result<()> { let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string()); // Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow) // Use /usr/bin/openssl - systemd services often have minimal PATH let mut hash_child = tokio::process::Command::new("/usr/bin/openssl") .args(["passwd", "-6", "-stdin"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?; { use tokio::io::AsyncWriteExt; let mut stdin = hash_child .stdin .take() .ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?; stdin.write_all(new_password.as_bytes()).await?; stdin.flush().await?; } let hash_result = hash_child.wait_with_output().await?; if !hash_result.status.success() { let stderr = String::from_utf8_lossy(&hash_result.stderr); anyhow::bail!("openssl passwd failed: {}", stderr); } let hash = String::from_utf8(hash_result.stdout)? .trim() .to_string(); if hash.is_empty() { anyhow::bail!("openssl passwd produced empty hash"); } // usermod -p writes directly to /etc/shadow, bypassing PAM // Use /usr/sbin/usermod - not always in systemd's PATH let status = tokio::process::Command::new("/usr/sbin/usermod") .args(["-p", &hash, &ssh_user]) .output() .await?; if !status.status.success() { let stderr = String::from_utf8_lossy(&status.stderr); anyhow::bail!("usermod failed: {}", stderr); } tracing::info!("SSH password updated for user {}", ssh_user); Ok(()) }