//! Single-admin authentication for the SimAdmin web console. use std::io::{self, Write}; use std::num::NonZeroU32; use anyhow::{bail, Result}; use axum::{ extract::State, http::{header, HeaderMap, HeaderValue, Method, StatusCode}, middleware::Next, response::{IntoResponse, Response}, Json, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use ring::{ digest, pbkdf2, rand::{SecureRandom, SystemRandom}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ config::SecurityConfig, db::Database, models::ApiResponse, state::AppState, system_event::{ codes as system_event_codes, severity as system_event_severity, status as system_event_status, }, }; const PASSWORD_KEY: &str = "admin_password_hash"; const PASSWORD_ALGORITHM: &str = "pbkdf2_sha256"; const PBKDF2_ITERATIONS: u32 = 210_000; const PASSWORD_SALT_LEN: usize = 16; const PASSWORD_HASH_LEN: usize = 32; const PASSWORD_MAX_LENGTH: usize = 64; const PASSWORD_MIN_LENGTH_MIN: u8 = 1; const PASSWORD_MIN_LENGTH_MAX: u8 = PASSWORD_MAX_LENGTH as u8; const SESSION_TOKEN_LEN: usize = 32; const SESSION_TTL_NEVER_SECONDS: i64 = 100 * 365 * 24 * 60 * 60; const SESSION_COOKIE: &str = "simadmin_session"; const SESSION_TTL_OPTIONS: [i64; 5] = [ 24 * 60 * 60, 7 * 24 * 60 * 60, 14 * 24 * 60 * 60, 30 * 24 * 60 * 60, -1, ]; const IDLE_TIMEOUT_OPTIONS: [i64; 6] = [30 * 60, 60 * 60, 2 * 60 * 60, 3 * 60 * 60, 6 * 60 * 60, 0]; #[derive(Debug, Deserialize)] pub struct LoginRequest { pub password: String, } #[derive(Debug, Deserialize)] pub struct ChangePasswordRequest { pub new_password: String, } #[derive(Debug, Serialize)] pub struct AuthStatusResponse { pub configured: bool, pub authenticated: bool, pub settings: SecurityConfig, } #[derive(Debug, Serialize)] pub struct AuthSettingsResponse { pub configured: bool, pub settings: SecurityConfig, } #[derive(Debug)] struct SessionToken { token: String, hash: String, } fn normalize_security_settings(mut settings: SecurityConfig) -> SecurityConfig { if !(PASSWORD_MIN_LENGTH_MIN..=PASSWORD_MIN_LENGTH_MAX).contains(&settings.password_min_length) { settings.password_min_length = SecurityConfig::default().password_min_length; } if !SESSION_TTL_OPTIONS.contains(&settings.session_ttl_seconds) { settings.session_ttl_seconds = SecurityConfig::default().session_ttl_seconds; } if !IDLE_TIMEOUT_OPTIONS.contains(&settings.idle_timeout_seconds) { settings.idle_timeout_seconds = SecurityConfig::default().idle_timeout_seconds; } if !settings.password_require_letters && !settings.password_require_digits && !settings.password_require_symbols { settings.password_require_letters = true; } settings } fn validate_security_settings(settings: &SecurityConfig) -> Result<()> { if !(PASSWORD_MIN_LENGTH_MIN..=PASSWORD_MIN_LENGTH_MAX).contains(&settings.password_min_length) { bail!("密码最小长度需为 1-64 之间的整数"); } if !settings.password_require_letters && !settings.password_require_digits && !settings.password_require_symbols { bail!("字符类型要求至少需要选择一项"); } if !SESSION_TTL_OPTIONS.contains(&settings.session_ttl_seconds) { bail!("会话有效期只能选择 1 天、7 天、14 天、30 天或永不过期"); } if !IDLE_TIMEOUT_OPTIONS.contains(&settings.idle_timeout_seconds) { bail!("空闲超时只能选择 30 分钟、1 小时、2 小时、3 小时、6 小时或关闭"); } Ok(()) } fn configured_session_ttl_seconds(settings: &SecurityConfig) -> i64 { if settings.session_ttl_seconds < 0 { SESSION_TTL_NEVER_SECONDS } else { settings.session_ttl_seconds } } fn enabled_password_types_text(settings: &SecurityConfig) -> &'static str { match ( settings.password_require_letters, settings.password_require_digits, settings.password_require_symbols, ) { (true, true, true) => "英文字母、数字和符号", (true, true, false) => "英文字母和数字", (true, false, true) => "英文字母和符号", (false, true, true) => "数字和符号", (true, false, false) => "英文字母", (false, true, false) => "数字", (false, false, true) => "符号", (false, false, false) => "英文字母、数字和符号", } } fn password_byte_allowed(byte: u8, settings: &SecurityConfig) -> bool { byte.is_ascii_graphic() && ((settings.password_require_letters && byte.is_ascii_alphabetic()) || (settings.password_require_digits && byte.is_ascii_digit()) || (settings.password_require_symbols && byte.is_ascii_graphic() && !byte.is_ascii_alphanumeric())) } pub fn validate_admin_password(password: &str, settings: &SecurityConfig) -> Result<()> { let settings = normalize_security_settings(settings.clone()); if !password .bytes() .all(|byte| password_byte_allowed(byte, &settings)) { bail!( "密码只能包含{},不能包含空格、中文或未启用的字符类型", enabled_password_types_text(&settings) ); } if !((settings.password_min_length as usize)..=PASSWORD_MAX_LENGTH).contains(&password.len()) { bail!( "密码长度需为 {}-{} 个字符", settings.password_min_length, PASSWORD_MAX_LENGTH ); } if settings.password_require_letters && !password.bytes().any(|byte| byte.is_ascii_alphabetic()) { bail!("密码需包含英文字母"); } if settings.password_require_digits && !password.bytes().any(|byte| byte.is_ascii_digit()) { bail!("密码需包含数字"); } if settings.password_require_symbols && !password .bytes() .any(|byte| byte.is_ascii_graphic() && !byte.is_ascii_alphanumeric()) { bail!("密码需包含符号"); } Ok(()) } pub fn hash_password(password: &str, settings: &SecurityConfig) -> Result { validate_admin_password(password, settings)?; let rng = SystemRandom::new(); let mut salt = [0u8; PASSWORD_SALT_LEN]; rng.fill(&mut salt) .map_err(|_| anyhow::anyhow!("Failed to generate password salt"))?; let mut output = [0u8; PASSWORD_HASH_LEN]; let iterations = NonZeroU32::new(PBKDF2_ITERATIONS).expect("non-zero iterations"); pbkdf2::derive( pbkdf2::PBKDF2_HMAC_SHA256, iterations, &salt, password.as_bytes(), &mut output, ); Ok(format!( "{}${}${}${}", PASSWORD_ALGORITHM, PBKDF2_ITERATIONS, URL_SAFE_NO_PAD.encode(salt), URL_SAFE_NO_PAD.encode(output) )) } fn verify_password(password: &str, encoded_hash: &str) -> Result { let parts: Vec<&str> = encoded_hash.split('$').collect(); if parts.len() != 4 || parts[0] != PASSWORD_ALGORITHM { bail!("Unsupported password hash format"); } let iterations = parts[1].parse::()?; let iterations = NonZeroU32::new(iterations).ok_or_else(|| anyhow::anyhow!("Invalid hash"))?; let salt = URL_SAFE_NO_PAD.decode(parts[2])?; let expected = URL_SAFE_NO_PAD.decode(parts[3])?; Ok(pbkdf2::verify( pbkdf2::PBKDF2_HMAC_SHA256, iterations, &salt, password.as_bytes(), &expected, ) .is_ok()) } fn generate_session_token() -> Result { let rng = SystemRandom::new(); let mut raw = [0u8; SESSION_TOKEN_LEN]; rng.fill(&mut raw) .map_err(|_| anyhow::anyhow!("Failed to generate session token"))?; let token = URL_SAFE_NO_PAD.encode(raw); let hash = hash_session_token(&token); Ok(SessionToken { token, hash }) } fn hash_session_token(token: &str) -> String { URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, token.as_bytes()).as_ref()) } fn session_cookie(token: &str, settings: &SecurityConfig) -> String { let max_age = configured_session_ttl_seconds(settings); format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}") } fn expired_session_cookie() -> String { format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0") } fn cookie_token(headers: &HeaderMap) -> Option { let cookie = headers.get(header::COOKIE)?.to_str().ok()?; cookie.split(';').find_map(|part| { let (name, value) = part.trim().split_once('=')?; (name == SESSION_COOKIE).then(|| value.to_string()) }) } fn wants_login_redirect(headers: &HeaderMap) -> bool { let accepts_html = headers .get(header::ACCEPT) .and_then(|value| value.to_str().ok()) .map(|value| value.contains("text/html")) .unwrap_or(false); let is_navigation = headers .get("sec-fetch-mode") .and_then(|value| value.to_str().ok()) .map(|value| value.eq_ignore_ascii_case("navigate")) .unwrap_or(false); accepts_html || is_navigation } fn unauthorized_response(headers: &HeaderMap, message: impl Into) -> Response { if wants_login_redirect(headers) { return (StatusCode::SEE_OTHER, [(header::LOCATION, "/login")]).into_response(); } ( StatusCode::UNAUTHORIZED, Json(ApiResponse::::error(message.into())), ) .into_response() } fn response_with_session( payload: ApiResponse, token: &str, settings: &SecurityConfig, ) -> Response { let mut response = Json(payload).into_response(); let cookie = session_cookie(token, settings); if let Ok(value) = HeaderValue::from_str(&cookie) { response.headers_mut().insert(header::SET_COOKIE, value); } response } fn is_authenticated(database: &Database, headers: &HeaderMap) -> bool { let Some(token) = cookie_token(headers) else { return false; }; database .auth_session_valid(&hash_session_token(&token)) .unwrap_or(false) } pub async fn auth_middleware( State(state): State, headers: HeaderMap, request: axum::extract::Request, next: Next, ) -> Response { if request.method() == Method::OPTIONS { return next.run(request).await; } let settings = normalize_security_settings(state.config_manager.get_security()); if !settings.password_protection_enabled { return next.run(request).await; } if !state.database.auth_is_configured().unwrap_or(false) { return unauthorized_response(&headers, "管理员密码尚未设置"); } if !is_authenticated(&state.database, &headers) { return unauthorized_response(&headers, "请先登录"); } next.run(request).await } pub async fn status( State(state): State, headers: HeaderMap, ) -> (StatusCode, Json>) { let settings = normalize_security_settings(state.config_manager.get_security()); let configured = state.database.auth_is_configured().unwrap_or(false); let authenticated = !settings.password_protection_enabled || (configured && is_authenticated(&state.database, &headers)); ( StatusCode::OK, Json(ApiResponse::success_with_message( "Success", AuthStatusResponse { configured, authenticated, settings, }, )), ) } pub async fn setup(State(state): State, Json(payload): Json) -> Response { let settings = normalize_security_settings(state.config_manager.get_security()); if state.database.auth_is_configured().unwrap_or(false) { return ( StatusCode::BAD_REQUEST, Json(ApiResponse::::error("管理员密码已设置")), ) .into_response(); } let password_hash = match hash_password(&payload.password, &settings) { Ok(hash) => hash, Err(err) => { return ( StatusCode::BAD_REQUEST, Json(ApiResponse::::error(err.to_string())), ) .into_response() } }; if let Err(err) = state .database .set_auth_config_value(PASSWORD_KEY, &password_hash) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!( "保存管理员密码失败: {err}" ))), ) .into_response(); } let session = match generate_session_token() { Ok(session) => session, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(err.to_string())), ) .into_response() } }; if let Err(err) = state .database .insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings)) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!("创建会话失败: {err}"))), ) .into_response(); } response_with_session( ApiResponse::success_with_message("Admin password configured", Value::Null), &session.token, &settings, ) } pub async fn login(State(state): State, Json(payload): Json) -> Response { let settings = normalize_security_settings(state.config_manager.get_security()); let Some(password_hash) = state .database .get_auth_config_value(PASSWORD_KEY) .unwrap_or(None) else { return ( StatusCode::BAD_REQUEST, Json(ApiResponse::::error("管理员密码尚未设置")), ) .into_response(); }; match verify_password(&payload.password, &password_hash) { Ok(true) => {} Ok(false) => { state.system_event_emitter.record_login_failure().await; return ( StatusCode::UNAUTHORIZED, Json(ApiResponse::::error("管理员密码不正确")), ) .into_response(); } Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!("验证密码失败: {err}"))), ) .into_response() } } let session = match generate_session_token() { Ok(session) => session, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(err.to_string())), ) .into_response() } }; if let Err(err) = state .database .insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings)) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!("创建会话失败: {err}"))), ) .into_response(); } response_with_session( ApiResponse::success_with_message("Logged in", Value::Null), &session.token, &settings, ) } pub async fn change_password( State(state): State, Json(payload): Json, ) -> Response { let settings = normalize_security_settings(state.config_manager.get_security()); let new_hash = match hash_password(&payload.new_password, &settings) { Ok(hash) => hash, Err(err) => { return ( StatusCode::BAD_REQUEST, Json(ApiResponse::::error(err.to_string())), ) .into_response() } }; if let Err(err) = state.database.replace_admin_password_hash(&new_hash) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!("更新密码失败: {err}"))), ) .into_response(); } let session = match generate_session_token() { Ok(session) => session, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(err.to_string())), ) .into_response() } }; if let Err(err) = state .database .insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings)) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!("创建会话失败: {err}"))), ) .into_response(); } state .system_event_emitter .emit_code( system_event_codes::SECURITY_PASSWORD_CHANGED, system_event_severity::WARNING, system_event_status::CHANGED, "admin", "管理员密码已修改", ) .await; response_with_session( ApiResponse::success_with_message("Password updated", Value::Null), &session.token, &settings, ) } pub async fn get_settings( State(state): State, ) -> (StatusCode, Json>) { let settings = normalize_security_settings(state.config_manager.get_security()); let configured = state.database.auth_is_configured().unwrap_or(false); ( StatusCode::OK, Json(ApiResponse::success_with_message( "Success", AuthSettingsResponse { configured, settings, }, )), ) } pub async fn set_settings( State(state): State, headers: HeaderMap, Json(payload): Json, ) -> Response { let previous_settings = normalize_security_settings(state.config_manager.get_security()); if let Err(err) = validate_security_settings(&payload) { return ( StatusCode::BAD_REQUEST, Json(ApiResponse::::error(err.to_string())), ) .into_response(); } let settings = normalize_security_settings(payload); if let Err(err) = state.config_manager.set_security(settings.clone()) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::::error(format!( "保存安全设置失败: {err}" ))), ) .into_response(); } if previous_settings.password_protection_enabled && !settings.password_protection_enabled { state .system_event_emitter .emit_code( system_event_codes::SECURITY_PASSWORD_PROTECTION_DISABLED, system_event_severity::CRITICAL, system_event_status::CHANGED, "security", "密码保护已关闭", ) .await; } else if previous_settings != settings { state .system_event_emitter .emit_code( system_event_codes::SECURITY_POLICY_CHANGED, system_event_severity::WARNING, system_event_status::CHANGED, "security", "安全策略已变更", ) .await; } let mut response = ( StatusCode::OK, Json(ApiResponse::success_with_message( "Security settings saved", settings.clone(), )), ) .into_response(); if let Some(token) = cookie_token(&headers) { let _ = state.database.refresh_auth_session( &hash_session_token(&token), configured_session_ttl_seconds(&settings), ); if let Ok(value) = HeaderValue::from_str(&session_cookie(&token, &settings)) { response.headers_mut().insert(header::SET_COOKIE, value); } } response } pub async fn logout(State(state): State, headers: HeaderMap) -> Response { if let Some(token) = cookie_token(&headers) { let _ = state .database .delete_auth_session(&hash_session_token(&token)); } let mut response = Json(ApiResponse::success_with_message("Logged out", Value::Null)).into_response(); if let Ok(value) = HeaderValue::from_str(&expired_session_cookie()) { response.headers_mut().insert(header::SET_COOKIE, value); } response } pub fn reset_admin_password_interactive( database: &Database, settings: &SecurityConfig, ) -> Result<()> { let password = read_password_line("New admin password: ")?; let confirm = read_password_line("Confirm admin password: ")?; if password != confirm { bail!("Passwords do not match"); } let hash = hash_password(&password, settings)?; database.replace_admin_password_hash(&hash)?; println!("Admin password updated and all web sessions were cleared."); Ok(()) } pub fn clear_admin_auth(database: &Database) -> Result<()> { database.clear_admin_auth()?; println!("Admin password and all web sessions were cleared."); println!("Open the web UI to set a new admin password."); Ok(()) } #[cfg(unix)] fn read_password_line(prompt: &str) -> Result { print!("{prompt}"); io::stdout().flush()?; let _ = std::process::Command::new("stty").arg("-echo").status(); let mut value = String::new(); let result = io::stdin().read_line(&mut value); let _ = std::process::Command::new("stty").arg("echo").status(); println!(); result?; Ok(value.trim_end_matches(['\r', '\n']).to_string()) } #[cfg(not(unix))] fn read_password_line(prompt: &str) -> Result { print!("{prompt}"); io::stdout().flush()?; let mut value = String::new(); io::stdin().read_line(&mut value)?; Ok(value.trim_end_matches(['\r', '\n']).to_string()) }