From 8bd16df6f80c9cf93f78615f3435050638f94a21 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 17 Jul 2026 16:34:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20TOTP=202FA=20?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 + src/auth/middleware.rs | 2 +- src/auth/mod.rs | 2 + src/auth/session.rs | 79 +++- src/auth/two_factor.rs | 498 ++++++++++++++++++++++++ src/config/schema/web.rs | 4 - src/config/store.rs | 54 ++- src/db/pool.rs | 17 + src/error.rs | 6 + src/main.rs | 25 +- src/state.rs | 5 +- src/web/error.rs | 10 + src/web/handlers/account.rs | 128 ++++++ src/web/handlers/auth.rs | 66 +++- src/web/handlers/config/auth.rs | 9 +- src/web/handlers/config/mod.rs | 2 - src/web/routes.rs | 11 + web/package-lock.json | 21 + web/package.json | 1 + web/src/api/index.ts | 52 ++- web/src/components/TotpSettingsCard.vue | 338 ++++++++++++++++ web/src/i18n/en-US.ts | 32 ++ web/src/i18n/zh-CN.ts | 32 ++ web/src/stores/auth.ts | 48 ++- web/src/types/generated.ts | 2 - web/src/views/LoginView.vue | 108 ++++- web/src/views/SettingsView.vue | 5 +- 27 files changed, 1505 insertions(+), 54 deletions(-) create mode 100644 src/auth/two_factor.rs create mode 100644 web/src/components/TotpSettingsCard.vue diff --git a/Cargo.toml b/Cargo.toml index d6bce3c8..32da9f3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ desktop = [ "dep:anyhow", "dep:argon2", "dep:rand", + "dep:totp-rs", "dep:uuid", "dep:base64", "dep:nix", @@ -100,6 +101,7 @@ anyhow = { version = "1", optional = true } # Authentication argon2 = { version = "0.5", optional = true } rand = { version = "0.10", optional = true } +totp-rs = { version = "5.7", features = ["gen_secret", "otpauth", "zeroize"], optional = true } # Utilities uuid = { version = "1", features = ["v4", "serde"], optional = true } diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index e0cc748e..625b67be 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -79,7 +79,7 @@ fn unauthorized_response(message: &str) -> Response { fn is_public_endpoint(path: &str) -> bool { matches!( path, - "/" | "/auth/login" | "/health" | "/setup" | "/setup/init" + "/" | "/auth/login" | "/auth/login/totp" | "/health" | "/setup" | "/setup/init" ) || path.starts_with("/assets/") || path.starts_with("/static/") || path.ends_with(".js") diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 8d9ba479..9caac93a 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,9 +1,11 @@ pub mod middleware; mod password; mod session; +mod two_factor; mod user; pub use middleware::{auth_middleware, SESSION_COOKIE}; pub use password::{hash_password, verify_password}; pub use session::{Session, SessionStore}; +pub use two_factor::{server_time_unix_ms, ChallengeInfo, EnrollmentInfo, TwoFactorService}; pub use user::{User, UserStore}; diff --git a/src/auth/session.rs b/src/auth/session.rs index 834b6429..4d7df287 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -39,18 +39,39 @@ impl SessionStore { } pub async fn create(&self, user_id: &str) -> Result { + let session = self.new_session(user_id); + let mut guard = self.inner.write().await; + guard.insert(session.id.clone(), session.clone()); + Ok(session) + } + + pub async fn create_for_login( + &self, + user_id: &str, + allow_multiple_sessions: bool, + ) -> Result<(Session, Vec)> { + let session = self.new_session(user_id); + let mut guard = self.inner.write().await; + let revoked = if allow_multiple_sessions { + Vec::new() + } else { + let ids = guard.keys().cloned().collect(); + guard.clear(); + ids + }; + guard.insert(session.id.clone(), session.clone()); + Ok((session, revoked)) + } + + fn new_session(&self, user_id: &str) -> Session { let now = OffsetDateTime::now_utc(); - let session = Session { + Session { id: Uuid::new_v4().to_string(), user_id: user_id.to_string(), created_at: now, expires_at: now + self.default_ttl, data: None, - }; - - let mut guard = self.inner.write().await; - guard.insert(session.id.clone(), session.clone()); - Ok(session) + } } pub async fn get(&self, session_id: &str) -> Result> { @@ -85,6 +106,17 @@ impl SessionStore { Ok(n) } + pub async fn delete_all_except(&self, session_id: &str) -> Result> { + let mut guard = self.inner.write().await; + let revoked: Vec = guard + .keys() + .filter(|id| id.as_str() != session_id) + .cloned() + .collect(); + guard.retain(|id, _| id == session_id); + Ok(revoked) + } + pub async fn list_ids(&self) -> Result> { let guard = self.inner.read().await; Ok(guard.keys().cloned().collect()) @@ -102,3 +134,38 @@ impl SessionStore { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn delete_all_except_preserves_only_current_session() { + let sessions = SessionStore::new(60); + let current = sessions.create("user").await.unwrap(); + let other = sessions.create("user").await.unwrap(); + + let revoked = sessions.delete_all_except(¤t.id).await.unwrap(); + assert_eq!(revoked, vec![other.id.clone()]); + assert!(sessions.get(¤t.id).await.unwrap().is_some()); + assert!(sessions.get(&other.id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn login_creation_applies_session_policy_atomically() { + let sessions = SessionStore::new(60); + let existing = sessions.create("user").await.unwrap(); + + let (multiple, revoked) = sessions.create_for_login("user", true).await.unwrap(); + assert!(revoked.is_empty()); + assert!(sessions.get(&existing.id).await.unwrap().is_some()); + assert!(sessions.get(&multiple.id).await.unwrap().is_some()); + + let (single, mut revoked) = sessions.create_for_login("user", false).await.unwrap(); + revoked.sort(); + let mut expected = vec![existing.id, multiple.id]; + expected.sort(); + assert_eq!(revoked, expected); + assert_eq!(sessions.list_ids().await.unwrap(), vec![single.id]); + } +} diff --git a/src/auth/two_factor.rs b/src/auth/two_factor.rs new file mode 100644 index 00000000..73b013bb --- /dev/null +++ b/src/auth/two_factor.rs @@ -0,0 +1,498 @@ +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use sqlx::{Pool, Sqlite}; +use tokio::sync::Mutex; +use totp_rs::{Algorithm, Secret, TOTP}; +use uuid::Uuid; + +use crate::error::{AppError, Result}; + +const LOGIN_TTL: Duration = Duration::from_secs(5 * 60); +const ENROLLMENT_TTL: Duration = Duration::from_secs(10 * 60); +const FAILURE_WINDOW: Duration = Duration::from_secs(60); +const MAX_FAILURES: usize = 5; + +struct LoginChallenge { + id: String, + user_id: String, + expires_at: Instant, + expires_at_unix_ms: u64, + failures: usize, +} + +struct EnrollmentChallenge { + id: String, + user_id: String, + secret: Secret, + expires_at: Instant, + expires_at_unix_ms: u64, + failures: usize, +} + +#[derive(Clone)] +pub struct ChallengeInfo { + pub id: String, + pub expires_at_unix_ms: u64, +} + +#[derive(Clone)] +pub struct EnrollmentInfo { + pub id: String, + pub secret: String, + pub otpauth_uri: String, + pub expires_at_unix_ms: u64, +} + +#[derive(Clone)] +pub struct TwoFactorService { + pool: Pool, + login_challenges: std::sync::Arc>>, + enrollment_challenges: std::sync::Arc>>, + failures: std::sync::Arc>>>, +} + +impl TwoFactorService { + pub fn new(pool: Pool) -> Self { + Self { + pool, + login_challenges: Default::default(), + enrollment_challenges: Default::default(), + failures: Default::default(), + } + } + + pub async fn is_enabled(&self, user_id: &str) -> Result { + let exists: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM user_totp_credentials WHERE user_id = ?1 LIMIT 1") + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + Ok(exists.is_some()) + } + + pub async fn begin_login(&self, user_id: &str) -> Result> { + if !self.is_enabled(user_id).await? { + return Ok(None); + } + + let challenge = LoginChallenge { + id: Uuid::new_v4().to_string(), + user_id: user_id.to_string(), + expires_at: Instant::now() + LOGIN_TTL, + expires_at_unix_ms: expires_at_unix_ms(LOGIN_TTL), + failures: 0, + }; + let info = ChallengeInfo { + id: challenge.id.clone(), + expires_at_unix_ms: challenge.expires_at_unix_ms, + }; + self.login_challenges + .lock() + .await + .insert(user_id.to_string(), challenge); + Ok(Some(info)) + } + + pub async fn complete_login(&self, challenge_id: &str, code: &str) -> Result { + validate_code_format(code)?; + + let (user_id, expired) = { + let challenges = self.login_challenges.lock().await; + let challenge = challenges + .values() + .find(|challenge| challenge.id == challenge_id) + .ok_or_else(|| AppError::AuthError("TOTP challenge expired".to_string()))?; + ( + challenge.user_id.clone(), + Instant::now() >= challenge.expires_at, + ) + }; + + if expired { + self.login_challenges.lock().await.remove(&user_id); + return Err(AppError::AuthError("TOTP challenge expired".to_string())); + } + self.enforce_failure_limit(&user_id).await?; + + let valid = match self.credential_secret(&user_id).await? { + Some(secret) => verify_at(&secret, code, unix_time_secs())?, + None => { + self.login_challenges.lock().await.remove(&user_id); + return Err(AppError::AuthError("TOTP challenge expired".to_string())); + } + }; + if !valid { + self.record_failure(&user_id).await; + let mut challenges = self.login_challenges.lock().await; + let mut exhausted = false; + if let Some(challenge) = challenges.get_mut(&user_id) { + challenge.failures += 1; + if challenge.failures >= MAX_FAILURES { + exhausted = true; + challenges.remove(&user_id); + } + } + if exhausted { + return Err(AppError::AuthError("TOTP challenge expired".to_string())); + } + return Err(AppError::AuthError("Invalid TOTP code".to_string())); + } + + let consumed = self + .login_challenges + .lock() + .await + .remove(&user_id) + .is_some_and(|challenge| challenge.id == challenge_id); + if !consumed { + return Err(AppError::AuthError("TOTP challenge expired".to_string())); + } + Ok(user_id) + } + + pub async fn begin_enrollment( + &self, + session_id: &str, + user_id: &str, + username: &str, + ) -> Result { + if self.is_enabled(user_id).await? { + return Err(AppError::Conflict("TOTP is already enabled".to_string())); + } + + let secret = Secret::generate_secret().to_encoded(); + let totp = totp(&secret, username)?; + let challenge = EnrollmentChallenge { + id: Uuid::new_v4().to_string(), + user_id: user_id.to_string(), + secret, + expires_at: Instant::now() + ENROLLMENT_TTL, + expires_at_unix_ms: expires_at_unix_ms(ENROLLMENT_TTL), + failures: 0, + }; + let info = EnrollmentInfo { + id: challenge.id.clone(), + secret: challenge.secret.to_string(), + otpauth_uri: totp.get_url(), + expires_at_unix_ms: challenge.expires_at_unix_ms, + }; + self.enrollment_challenges + .lock() + .await + .insert(session_id.to_string(), challenge); + Ok(info) + } + + pub async fn confirm_enrollment( + &self, + session_id: &str, + user_id: &str, + enrollment_id: &str, + code: &str, + ) -> Result<()> { + validate_code_format(code)?; + self.enforce_failure_limit(user_id).await?; + + let (secret, expired) = { + let challenges = self.enrollment_challenges.lock().await; + let challenge = challenges + .get(session_id) + .filter(|challenge| challenge.id == enrollment_id && challenge.user_id == user_id) + .ok_or_else(|| AppError::AuthError("TOTP enrollment expired".to_string()))?; + ( + challenge.secret.clone(), + Instant::now() >= challenge.expires_at, + ) + }; + if expired { + self.enrollment_challenges.lock().await.remove(session_id); + return Err(AppError::AuthError("TOTP enrollment expired".to_string())); + } + + if !verify_at(&secret, code, unix_time_secs())? { + self.record_failure(user_id).await; + let mut challenges = self.enrollment_challenges.lock().await; + let mut exhausted = false; + if let Some(challenge) = challenges.get_mut(session_id) { + challenge.failures += 1; + if challenge.failures >= MAX_FAILURES { + exhausted = true; + challenges.remove(session_id); + } + } + if exhausted { + return Err(AppError::AuthError("TOTP enrollment expired".to_string())); + } + return Err(AppError::AuthError("Invalid TOTP code".to_string())); + } + + let mut transaction = self.pool.begin().await?; + let result = + sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)") + .bind(user_id) + .bind(secret.to_string()) + .execute(&mut *transaction) + .await; + match result { + Ok(_) => transaction.commit().await?, + Err(sqlx::Error::Database(error)) if error.is_unique_violation() => { + return Err(AppError::Conflict("TOTP is already enabled".to_string())); + } + Err(error) => return Err(error.into()), + } + self.enrollment_challenges.lock().await.remove(session_id); + Ok(()) + } + + pub async fn disable(&self, user_id: &str, code: &str) -> Result<()> { + validate_code_format(code)?; + self.enforce_failure_limit(user_id).await?; + let secret = self + .credential_secret(user_id) + .await? + .ok_or_else(|| AppError::Conflict("TOTP is not enabled".to_string()))?; + if !verify_at(&secret, code, unix_time_secs())? { + self.record_failure(user_id).await; + return Err(AppError::AuthError("Invalid TOTP code".to_string())); + } + + let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1") + .bind(user_id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(AppError::Conflict("TOTP is not enabled".to_string())); + } + self.clear_user_challenges(user_id).await; + Ok(()) + } + + pub async fn disable_without_code(&self, user_id: &str) -> Result { + let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1") + .bind(user_id) + .execute(&self.pool) + .await?; + self.clear_user_challenges(user_id).await; + Ok(result.rows_affected() > 0) + } + + async fn credential_secret(&self, user_id: &str) -> Result> { + let row: Option<(String,)> = + sqlx::query_as("SELECT secret FROM user_totp_credentials WHERE user_id = ?1") + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(secret,)| Secret::Encoded(secret))) + } + + async fn enforce_failure_limit(&self, user_id: &str) -> Result<()> { + let now = Instant::now(); + let mut failures = self.failures.lock().await; + let attempts = failures.entry(user_id.to_string()).or_default(); + while attempts + .front() + .is_some_and(|attempt| now.duration_since(*attempt) >= FAILURE_WINDOW) + { + attempts.pop_front(); + } + if attempts.len() >= MAX_FAILURES { + return Err(AppError::RateLimited( + "TOTP verification is temporarily limited".to_string(), + )); + } + Ok(()) + } + + async fn record_failure(&self, user_id: &str) { + self.failures + .lock() + .await + .entry(user_id.to_string()) + .or_default() + .push_back(Instant::now()); + } + + async fn clear_user_challenges(&self, user_id: &str) { + self.login_challenges.lock().await.remove(user_id); + self.enrollment_challenges + .lock() + .await + .retain(|_, challenge| challenge.user_id != user_id); + self.failures.lock().await.remove(user_id); + } +} + +fn totp(secret: &Secret, account_name: &str) -> Result { + let account_name = account_name.replace(':', "_"); + TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + secret + .to_bytes() + .map_err(|error| AppError::Internal(error.to_string()))?, + Some("One-KVM".to_string()), + account_name, + ) + .map_err(|error| AppError::Internal(error.to_string())) +} + +fn verify_at(secret: &Secret, code: &str, unix_time: u64) -> Result { + validate_code_format(code)?; + Ok(totp(secret, "user")?.check(code, unix_time)) +} + +fn validate_code_format(code: &str) -> Result<()> { + if code.len() != 6 || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(AppError::BadRequest( + "TOTP code must contain exactly 6 digits".to_string(), + )); + } + Ok(()) +} + +pub fn server_time_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn unix_time_secs() -> u64 { + server_time_unix_ms() / 1000 +} + +fn expires_at_unix_ms(ttl: Duration) -> u64 { + server_time_unix_ms().saturating_add(ttl.as_millis() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::DatabasePool; + use tempfile::tempdir; + + async fn test_service() -> (tempfile::TempDir, TwoFactorService, String) { + let dir = tempdir().unwrap(); + let db = DatabasePool::new(&dir.path().join("test.db")) + .await + .unwrap(); + db.init_schema().await.unwrap(); + let user_id = "user-1".to_string(); + sqlx::query("INSERT INTO users (id, username, password_hash) VALUES (?1, 'admin', 'hash')") + .bind(&user_id) + .execute(db.pool()) + .await + .unwrap(); + let service = TwoFactorService::new(db.clone_pool()); + (dir, service, user_id) + } + + async fn install_known_credential(service: &TwoFactorService, user_id: &str) -> Secret { + let secret = Secret::Raw(b"12345678901234567890".to_vec()).to_encoded(); + sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)") + .bind(user_id) + .bind(secret.to_string()) + .execute(&service.pool) + .await + .unwrap(); + secret + } + + #[test] + fn accepts_rfc_vector_and_adjacent_window() { + let secret = Secret::Raw(b"12345678901234567890".to_vec()); + assert!(verify_at(&secret, "287082", 59).unwrap()); + let code = totp(&secret, "user").unwrap().generate(30); + assert!(verify_at(&secret, &code, 60).unwrap()); + } + + #[test] + fn rejects_malformed_codes() { + let secret = Secret::Raw(b"12345678901234567890".to_vec()); + assert!(verify_at(&secret, "12345", 59).is_err()); + assert!(verify_at(&secret, "12345x", 59).is_err()); + } + + #[tokio::test] + async fn login_challenges_are_replaced_expire_and_are_consumed_once() { + let (_dir, service, user_id) = test_service().await; + let secret = install_known_credential(&service, &user_id).await; + let first = service.begin_login(&user_id).await.unwrap().unwrap(); + let second = service.begin_login(&user_id).await.unwrap().unwrap(); + let code = totp(&secret, "user").unwrap().generate_current().unwrap(); + + assert!(service.complete_login(&first.id, &code).await.is_err()); + assert_eq!( + service.complete_login(&second.id, &code).await.unwrap(), + user_id + ); + assert!(service.complete_login(&second.id, &code).await.is_err()); + + let expired = service.begin_login(&user_id).await.unwrap().unwrap(); + service + .login_challenges + .lock() + .await + .get_mut(&user_id) + .unwrap() + .expires_at = Instant::now() - Duration::from_secs(1); + assert!(service.complete_login(&expired.id, &code).await.is_err()); + } + + #[tokio::test] + async fn challenge_failure_limit_is_shared_across_new_challenges() { + let (_dir, service, user_id) = test_service().await; + let secret = install_known_credential(&service, &user_id).await; + let valid = totp(&secret, "user").unwrap().generate_current().unwrap(); + let invalid = if valid == "000000" { + "000001" + } else { + "000000" + }; + let challenge = service.begin_login(&user_id).await.unwrap().unwrap(); + + for _ in 0..4 { + let error = service + .complete_login(&challenge.id, invalid) + .await + .unwrap_err(); + assert!(matches!(error, AppError::AuthError(_))); + } + let error = service + .complete_login(&challenge.id, invalid) + .await + .unwrap_err(); + assert!(error.to_string().contains("challenge expired")); + + let replacement = service.begin_login(&user_id).await.unwrap().unwrap(); + let error = service + .complete_login(&replacement.id, &valid) + .await + .unwrap_err(); + assert!(matches!(error, AppError::RateLimited(_))); + } + + #[tokio::test] + async fn enrollment_persists_and_disable_is_idempotent_for_cli() { + let (_dir, service, user_id) = test_service().await; + let enrollment = service + .begin_enrollment("session-1", &user_id, "admin") + .await + .unwrap(); + let secret = Secret::Encoded(enrollment.secret.clone()); + let code = totp(&secret, "admin").unwrap().generate_current().unwrap(); + service + .confirm_enrollment("session-1", &user_id, &enrollment.id, &code) + .await + .unwrap(); + + let restarted = TwoFactorService::new(service.pool.clone()); + assert!(restarted.is_enabled(&user_id).await.unwrap()); + restarted.disable(&user_id, &code).await.unwrap(); + assert!(!restarted.is_enabled(&user_id).await.unwrap()); + assert!(!restarted.disable_without_code(&user_id).await.unwrap()); + } +} diff --git a/src/config/schema/web.rs b/src/config/schema/web.rs index d835ccf1..6671855d 100644 --- a/src/config/schema/web.rs +++ b/src/config/schema/web.rs @@ -7,8 +7,6 @@ use typeshare::typeshare; pub struct AuthConfig { pub session_timeout_secs: u32, pub single_user_allow_multiple_sessions: bool, - pub totp_enabled: bool, - pub totp_secret: Option, } impl Default for AuthConfig { @@ -16,8 +14,6 @@ impl Default for AuthConfig { Self { session_timeout_secs: 3600 * 24, single_user_allow_multiple_sessions: false, - totp_enabled: false, - totp_secret: None, } } } diff --git a/src/config/store.rs b/src/config/store.rs index 426d8073..4a56323a 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -27,13 +27,16 @@ impl ConfigStore { } pub async fn load(&self) -> Result<()> { - let mut config = Self::load_config(&self.pool).await?; + let (mut config, removed_legacy_totp) = Self::load_config(&self.pool).await?; config.enforce_invariants(); + if removed_legacy_totp { + Self::save_config_to_db(&self.pool, &config).await?; + } self.cache.store(Arc::new(config)); Ok(()) } - async fn load_config(pool: &Pool) -> Result { + async fn load_config(pool: &Pool) -> Result<(AppConfig, bool)> { let row: Option<(String,)> = sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'") .fetch_optional(pool) @@ -41,12 +44,24 @@ impl ConfigStore { match row { Some((json,)) => { - serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string())) + let mut value: serde_json::Value = + serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?; + let mut removed = false; + if let Some(auth) = value + .get_mut("auth") + .and_then(|value| value.as_object_mut()) + { + removed |= auth.remove("totp_enabled").is_some(); + removed |= auth.remove("totp_secret").is_some(); + } + let config = + serde_json::from_value(value).map_err(|e| AppError::Config(e.to_string()))?; + Ok((config, removed)) } None => { let config = AppConfig::default(); Self::save_config_to_db(pool, &config).await?; - Ok(config) + Ok((config, false)) } } } @@ -174,4 +189,35 @@ mod tests { .is_err()); assert!(!store.get().watchdog.enabled); } + + #[tokio::test] + async fn load_removes_legacy_totp_fields_from_persisted_config() { + let dir = tempdir().unwrap(); + let db = DatabasePool::new(&dir.path().join("test.db")) + .await + .unwrap(); + db.init_schema().await.unwrap(); + let mut value = serde_json::to_value(AppConfig::default()).unwrap(); + let auth = value.get_mut("auth").unwrap().as_object_mut().unwrap(); + auth.insert("totp_enabled".to_string(), serde_json::json!(true)); + auth.insert( + "totp_secret".to_string(), + serde_json::json!("legacy-secret"), + ); + sqlx::query("INSERT INTO config (key, value) VALUES ('app_config', ?1)") + .bind(value.to_string()) + .execute(db.pool()) + .await + .unwrap(); + + let store = ConfigStore::new(db.clone_pool()).unwrap(); + store.load().await.unwrap(); + let (persisted,): (String,) = + sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert!(!persisted.contains("totp_enabled")); + assert!(!persisted.contains("totp_secret")); + } } diff --git a/src/db/pool.rs b/src/db/pool.rs index ec2f042b..b06dd4bc 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -30,6 +30,7 @@ impl DatabasePool { pub async fn init_schema(&self) -> Result<()> { self.create_config_table().await?; self.create_users_table().await?; + self.create_user_totp_credentials_table().await?; self.create_api_tokens_table().await?; self.create_wol_history_table().await?; Ok(()) @@ -86,6 +87,22 @@ impl DatabasePool { Ok(()) } + async fn create_user_totp_credentials_table(&self) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS user_totp_credentials ( + user_id TEXT PRIMARY KEY, + secret TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + "#, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + async fn create_wol_history_table(&self) -> Result<()> { sqlx::query( r#" diff --git a/src/error.rs b/src/error.rs index 9dba75a1..390f3511 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,12 @@ pub enum AppError { #[error("Bad request: {0}")] BadRequest(String), + #[error("Conflict: {0}")] + Conflict(String), + + #[error("Too many attempts: {0}")] + RateLimited(String), + #[error("Persistence error: {0}")] Persistence(String), diff --git a/src/main.rs b/src/main.rs index ee9a9026..0a3752f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use one_kvm::atx::AtxController; use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality}; -use one_kvm::auth::{SessionStore, UserStore}; +use one_kvm::auth::{SessionStore, TwoFactorService, UserStore}; use one_kvm::computer_use::ComputerUseManager; use one_kvm::config::{self, AppConfig, ConfigStore}; use one_kvm::db::DatabasePool; @@ -118,6 +118,8 @@ struct UserCommand { enum UserAction { /// Set password for the single local user (interactive terminal prompt) SetPassword, + /// Disable TOTP for the single local user + DisableTotp, } #[tokio::main] @@ -188,6 +190,7 @@ async fn main() -> anyhow::Result<()> { let session_store = SessionStore::new(config.auth.session_timeout_secs as i64); let user_store = UserStore::new(db.clone_pool()); + let two_factor = TwoFactorService::new(db.clone_pool()); let (shutdown_tx, _) = broadcast::channel::(1); @@ -571,6 +574,7 @@ async fn main() -> anyhow::Result<()> { config_store.clone(), session_store, user_store, + two_factor, #[cfg(unix)] otg_service, stream_manager, @@ -893,10 +897,13 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu tokio::fs::create_dir_all(&data_dir).await?; let db = open_database_pool(&data_dir).await?; let users = UserStore::new(db.clone_pool()); + let two_factor = TwoFactorService::new(db.clone_pool()); let sessions = SessionStore::new(0); match command { - CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await, + CliCommand::User(user) => { + run_user_action(user.action, &users, &sessions, &two_factor).await + } } } @@ -962,12 +969,26 @@ async fn run_user_action( action: UserAction, users: &UserStore, sessions: &SessionStore, + two_factor: &TwoFactorService, ) -> anyhow::Result<()> { match action { UserAction::SetPassword => set_user_password(users, sessions).await, + UserAction::DisableTotp => disable_user_totp(users, two_factor).await, } } +async fn disable_user_totp(users: &UserStore, two_factor: &TwoFactorService) -> anyhow::Result<()> { + let user = users.single_user().await?.ok_or_else(|| { + anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.") + })?; + if two_factor.disable_without_code(&user.id).await? { + println!("TOTP disabled for user '{}'.", user.username); + } else { + println!("TOTP is already disabled for user '{}'.", user.username); + } + Ok(()) +} + async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> { let user = users.single_user().await?.ok_or_else(|| { anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.") diff --git a/src/state.rs b/src/state.rs index e554451b..67b6b57d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,7 +3,7 @@ use tokio::sync::{broadcast, watch, Mutex, RwLock}; use crate::atx::AtxController; use crate::audio::AudioController; -use crate::auth::{SessionStore, UserStore}; +use crate::auth::{SessionStore, TwoFactorService, UserStore}; use crate::computer_use::ComputerUseManager; use crate::config::ConfigStore; use crate::db::DatabasePool; @@ -66,6 +66,7 @@ pub struct AppState { pub config: ConfigStore, pub sessions: SessionStore, pub users: UserStore, + pub two_factor: TwoFactorService, #[cfg(unix)] pub otg_service: Arc, pub stream_manager: Arc, @@ -97,6 +98,7 @@ impl AppState { config: ConfigStore, sessions: SessionStore, users: UserStore, + two_factor: TwoFactorService, #[cfg(unix)] otg_service: Arc, stream_manager: Arc, webrtc: Arc, @@ -121,6 +123,7 @@ impl AppState { config, sessions, users, + two_factor, #[cfg(unix)] otg_service, stream_manager, diff --git a/src/web/error.rs b/src/web/error.rs index 8ecc8b3c..41edda1c 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -34,6 +34,8 @@ fn status_code(error: &AppError) -> StatusCode { match error { AppError::AuthError(_) | AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Conflict(_) => StatusCode::CONFLICT, + AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, AppError::NotFound(_) => StatusCode::NOT_FOUND, AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::INTERNAL_SERVER_ERROR, @@ -62,6 +64,14 @@ mod tests { status_code(&AppError::ServiceUnavailable("offline".to_string())), StatusCode::SERVICE_UNAVAILABLE ); + assert_eq!( + status_code(&AppError::Conflict("exists".to_string())), + StatusCode::CONFLICT + ); + assert_eq!( + status_code(&AppError::RateLimited("limited".to_string())), + StatusCode::TOO_MANY_REQUESTS + ); } #[test] diff --git a/src/web/handlers/account.rs b/src/web/handlers/account.rs index d5975f48..a86e9bce 100644 --- a/src/web/handlers/account.rs +++ b/src/web/handlers/account.rs @@ -1,4 +1,5 @@ use super::*; +use crate::auth::server_time_unix_ms; use crate::state::ShutdownAction; /// Change password request @@ -8,6 +9,133 @@ pub struct ChangePasswordRequest { pub new_password: String, } +#[derive(Serialize)] +pub struct TotpStatusResponse { + pub enabled: bool, + pub server_time_unix_ms: u64, +} + +#[derive(Deserialize)] +pub struct BeginTotpEnrollmentRequest { + pub current_password: String, +} + +#[derive(Serialize)] +pub struct TotpEnrollmentResponse { + pub enrollment_id: String, + pub secret: String, + pub otpauth_uri: String, + pub expires_at_unix_ms: u64, + pub server_time_unix_ms: u64, +} + +#[derive(Deserialize)] +pub struct ConfirmTotpEnrollmentRequest { + pub enrollment_id: String, + pub code: String, +} + +#[derive(Deserialize)] +pub struct DisableTotpRequest { + pub current_password: String, + pub code: String, +} + +pub async fn totp_status( + State(state): State>, + axum::Extension(session): axum::Extension, +) -> Result> { + Ok(Json(TotpStatusResponse { + enabled: state.two_factor.is_enabled(&session.user_id).await?, + server_time_unix_ms: server_time_unix_ms(), + })) +} + +pub async fn begin_totp_enrollment( + State(state): State>, + axum::Extension(session): axum::Extension, + Json(req): Json, +) -> Result> { + let user = authenticated_user(&state, &session).await?; + verify_current_password(&state, &user, &req.current_password).await?; + let enrollment = state + .two_factor + .begin_enrollment(&session.id, &user.id, &user.username) + .await?; + Ok(Json(TotpEnrollmentResponse { + enrollment_id: enrollment.id, + secret: enrollment.secret, + otpauth_uri: enrollment.otpauth_uri, + expires_at_unix_ms: enrollment.expires_at_unix_ms, + server_time_unix_ms: server_time_unix_ms(), + })) +} + +pub async fn confirm_totp_enrollment( + State(state): State>, + axum::Extension(session): axum::Extension, + Json(req): Json, +) -> Result> { + authenticated_user(&state, &session).await?; + state + .two_factor + .confirm_enrollment(&session.id, &session.user_id, &req.enrollment_id, &req.code) + .await?; + revoke_other_sessions(&state, &session.id).await?; + Ok(Json(LoginResponse { + success: true, + message: None, + })) +} + +pub async fn disable_totp( + State(state): State>, + axum::Extension(session): axum::Extension, + Json(req): Json, +) -> Result> { + let user = authenticated_user(&state, &session).await?; + verify_current_password(&state, &user, &req.current_password).await?; + state.two_factor.disable(&user.id, &req.code).await?; + revoke_other_sessions(&state, &session.id).await?; + Ok(Json(LoginResponse { + success: true, + message: None, + })) +} + +async fn authenticated_user(state: &Arc, session: &Session) -> Result { + state + .users + .single_user() + .await? + .filter(|user| user.id == session.user_id) + .ok_or_else(|| AppError::AuthError("Invalid session".to_string())) +} + +async fn verify_current_password( + state: &Arc, + user: &crate::auth::User, + password: &str, +) -> Result<()> { + if state + .users + .verify(&user.username, password) + .await? + .is_none() + { + return Err(AppError::AuthError( + "Current password is incorrect".to_string(), + )); + } + Ok(()) +} + +async fn revoke_other_sessions(state: &Arc, current_session_id: &str) -> Result<()> { + let revoked = state.sessions.delete_all_except(current_session_id).await?; + state.remember_revoked_sessions(revoked).await; + Ok(()) +} + /// Change current user's password pub async fn change_password( State(state): State>, diff --git a/src/web/handlers/auth.rs b/src/web/handlers/auth.rs index babf37a9..0d088d1e 100644 --- a/src/web/handlers/auth.rs +++ b/src/web/handlers/auth.rs @@ -12,11 +12,26 @@ pub struct LoginResponse { pub message: Option, } +#[derive(Serialize)] +pub struct AuthLoginResponse { + pub next: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub challenge_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at_unix_ms: Option, +} + +#[derive(Deserialize)] +pub struct TotpLoginRequest { + pub challenge_id: String, + pub code: String, +} + pub async fn login( State(state): State>, cookies: CookieJar, Json(req): Json, -) -> Result<(CookieJar, Json)> { +) -> Result<(CookieJar, Json)> { let config = state.config.get(); // Check if system is initialized @@ -31,15 +46,43 @@ pub async fn login( .await? .ok_or_else(|| AppError::AuthError("Invalid username or password".to_string()))?; - if !config.auth.single_user_allow_multiple_sessions { - // Kick existing sessions before creating a new one. - let revoked_ids = state.sessions.list_ids().await?; - state.sessions.delete_all().await?; - state.remember_revoked_sessions(revoked_ids).await; + if let Some(challenge) = state.two_factor.begin_login(&user.id).await? { + return Ok(( + cookies, + Json(AuthLoginResponse { + next: "totp", + challenge_id: Some(challenge.id), + expires_at_unix_ms: Some(challenge.expires_at_unix_ms), + }), + )); } - // Create session - let session = state.sessions.create(&user.id).await?; + create_authenticated_session(&state, cookies, &user.id).await +} + +pub async fn login_totp( + State(state): State>, + cookies: CookieJar, + Json(req): Json, +) -> Result<(CookieJar, Json)> { + let user_id = state + .two_factor + .complete_login(&req.challenge_id, &req.code) + .await?; + create_authenticated_session(&state, cookies, &user_id).await +} + +async fn create_authenticated_session( + state: &Arc, + cookies: CookieJar, + user_id: &str, +) -> Result<(CookieJar, Json)> { + let config = state.config.get(); + let (session, revoked_ids) = state + .sessions + .create_for_login(user_id, config.auth.single_user_allow_multiple_sessions) + .await?; + state.remember_revoked_sessions(revoked_ids).await; // Set session cookie let cookie = Cookie::build((SESSION_COOKIE, session.id)) @@ -53,9 +96,10 @@ pub async fn login( Ok(( cookies.add(cookie), - Json(LoginResponse { - success: true, - message: None, + Json(AuthLoginResponse { + next: "authenticated", + challenge_id: None, + expires_at_unix_ms: None, }), )) } diff --git a/src/web/handlers/config/auth.rs b/src/web/handlers/config/auth.rs index 8c7f6d05..5e358307 100644 --- a/src/web/handlers/config/auth.rs +++ b/src/web/handlers/config/auth.rs @@ -7,11 +7,8 @@ use crate::state::AppState; use super::types::AuthConfigUpdate; -/// Get auth configuration (sensitive fields are cleared) pub async fn get_auth_config(State(state): State>) -> Json { - let mut auth = state.config.get().auth.clone(); - auth.totp_secret = None; - Json(auth) + Json(state.config.get().auth.clone()) } pub async fn update_auth_config( @@ -26,7 +23,5 @@ pub async fn update_auth_config( }) .await?; - let mut auth = state.config.get().auth.clone(); - auth.totp_secret = None; - Ok(Json(auth)) + Ok(Json(state.config.get().auth.clone())) } diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 5c933564..22442630 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -54,8 +54,6 @@ use crate::config::AppConfig; use crate::state::AppState; fn sanitize_config_for_api(config: &mut AppConfig) { - config.auth.totp_secret = None; - config.stream.turn_password = None; config.computer_use.api_key = None; diff --git a/src/web/routes.rs b/src/web/routes.rs index 0d03c8ec..c2527394 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -38,6 +38,7 @@ pub fn create_router(state: Arc) -> Router { let public_routes = Router::new() .route("/health", get(handlers::health_check)) .route("/auth/login", post(handlers::login)) + .route("/auth/login/totp", post(handlers::login_totp)) .route("/setup", get(handlers::setup_status)) .route("/setup/init", post(handlers::setup_init)); @@ -48,6 +49,16 @@ pub fn create_router(state: Arc) -> Router { .route("/auth/check", get(handlers::auth_check)) .route("/auth/password", post(handlers::change_password)) .route("/auth/username", post(handlers::change_username)) + .route("/auth/totp", get(handlers::totp_status)) + .route( + "/auth/totp/enrollment", + post(handlers::begin_totp_enrollment), + ) + .route( + "/auth/totp/enrollment/confirm", + post(handlers::confirm_totp_enrollment), + ) + .route("/auth/totp/disable", post(handlers::disable_totp)) .route("/devices", get(handlers::list_devices)) // WebSocket endpoint for real-time events .route("/ws", any(ws_handler)) diff --git a/web/package-lock.json b/web/package-lock.json index 6273f71d..74683eb1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "lucide-vue-next": "^0.556.0", "opus-decoder": "^0.7.11", "pinia": "^3.0.4", + "qrcode.vue": "^3.10.0", "reka-ui": "^2.10.1", "simple-keyboard": "^3.8.163", "tailwind-merge": "^3.6.0", @@ -85,6 +86,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -2556,6 +2568,15 @@ "dev": true, "license": "MIT" }, + "node_modules/qrcode.vue": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.10.0.tgz", + "integrity": "sha512-1bjeBds9hRKMszZuBuYQZor9HdJYWtb0S44HG1JofZ9uicpJpdF+TtDvqo3+2ZlH0k80WVIkmiKB4hq0/N6/rA==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/reka-ui": { "version": "2.10.1", "resolved": "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", diff --git a/web/package.json b/web/package.json index 89905898..4a35b0ac 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "lucide-vue-next": "^0.556.0", "opus-decoder": "^0.7.11", "pinia": "^3.0.4", + "qrcode.vue": "^3.10.0", "reka-ui": "^2.10.1", "simple-keyboard": "^3.8.163", "tailwind-merge": "^3.6.0", diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2266399f..13c78366 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -15,7 +15,7 @@ const API_BASE = '/api' export const authApi = { login: (username: string, password: string) => - request<{ success: boolean; message?: string }>( + request( '/auth/login', { method: 'POST', @@ -24,6 +24,16 @@ export const authApi = { { toastOnError: false }, ), + loginTotp: (challengeId: string, code: string) => + request( + '/auth/login/totp', + { + method: 'POST', + body: JSON.stringify({ challenge_id: challengeId, code }), + }, + { toastOnError: false }, + ), + logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST' }), @@ -41,6 +51,46 @@ export const authApi = { method: 'POST', body: JSON.stringify({ username, current_password: currentPassword }), }), + + totpStatus: () => + request('/auth/totp'), + + beginTotpEnrollment: (currentPassword: string) => + request('/auth/totp/enrollment', { + method: 'POST', + body: JSON.stringify({ current_password: currentPassword }), + }, { toastOnError: false }), + + confirmTotpEnrollment: (enrollmentId: string, code: string) => + request<{ success: boolean }>('/auth/totp/enrollment/confirm', { + method: 'POST', + body: JSON.stringify({ enrollment_id: enrollmentId, code }), + }, { toastOnError: false }), + + disableTotp: (currentPassword: string, code: string) => + request<{ success: boolean }>('/auth/totp/disable', { + method: 'POST', + body: JSON.stringify({ current_password: currentPassword, code }), + }, { toastOnError: false }), +} + +export interface AuthLoginResponse { + next: 'authenticated' | 'totp' + challenge_id?: string + expires_at_unix_ms?: number +} + +export interface TotpStatusResponse { + enabled: boolean + server_time_unix_ms: number +} + +export interface TotpEnrollmentResponse { + enrollment_id: string + secret: string + otpauth_uri: string + expires_at_unix_ms: number + server_time_unix_ms: number } export interface NetworkAddress { diff --git a/web/src/components/TotpSettingsCard.vue b/web/src/components/TotpSettingsCard.vue new file mode 100644 index 00000000..9558eadf --- /dev/null +++ b/web/src/components/TotpSettingsCard.vue @@ -0,0 +1,338 @@ + + + diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index bb85f4ec..954b2553 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -74,6 +74,15 @@ export default { forgotPassword: 'Forgot password', forgotPasswordHint: 'Forgot your password? On the host running One-KVM, open a terminal and run one-kvm user set-password, then enter and confirm the new password when prompted.', + totpPrompt: 'Enter the code from your authenticator app', + totpCode: 'Authenticator code', + totpCodePlaceholder: '6-digit code', + enterTotpCode: 'Enter a 6-digit authenticator code', + invalidTotpCode: 'The authenticator code is incorrect', + totpChallengeExpired: 'This login attempt has expired. Enter your password again.', + totpRateLimited: 'Too many incorrect codes. Wait one minute, then sign in again.', + verifyAndLogin: 'Verify and login', + backToPassword: 'Back to password', }, status: { connected: 'Connected', @@ -469,6 +478,29 @@ export default { newPassword: 'New Password', usernameDesc: 'Change the console login username', passwordDesc: 'Change the console login password', + totp: { + title: 'Two-factor authentication', + description: 'Require an authenticator code when signing in to the web console', + serverTime: 'Server time', + localTime: 'Local time', + clockOffset: 'Clock offset: {seconds} seconds', + clockDriftWarning: 'The server and this device differ by at least 30 seconds. Codes may fail until their clocks are corrected.', + enable: 'Enable 2FA', + disable: 'Disable 2FA', + enableTitle: 'Set up two-factor authentication', + disableTitle: 'Disable two-factor authentication', + manualSecret: 'Manual setup key', + secretOneTime: 'This key is shown only during setup. Store it in your authenticator now.', + enterSixDigitCode: 'Enter a 6-digit authenticator code', + currentPasswordIncorrect: 'The current password is incorrect', + invalidCode: 'The authenticator code is incorrect', + enrollmentExpired: 'Setup expired. Enter your password to start again.', + rateLimited: 'Too many incorrect codes. Wait one minute and try again.', + alreadyEnabled: 'Two-factor authentication is already enabled', + notEnabled: 'Two-factor authentication is not enabled', + operationFailed: 'Unable to update two-factor authentication', + loadFailed: 'Unable to load two-factor authentication status', + }, httpPort: 'HTTP Port', httpsPort: 'HTTPS Port', bindAddress: 'Bind Address', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 1cb6387f..c236d47c 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -74,6 +74,15 @@ export default { forgotPassword: '忘记密码', forgotPasswordHint: '忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password,按提示输入并确认新密码即可重置。', + totpPrompt: '请输入认证器应用中的验证码', + totpCode: '认证器验证码', + totpCodePlaceholder: '6 位验证码', + enterTotpCode: '请输入 6 位认证器验证码', + invalidTotpCode: '认证器验证码错误', + totpChallengeExpired: '本次登录验证已过期,请重新输入密码。', + totpRateLimited: '错误次数过多,请等待一分钟后重新登录。', + verifyAndLogin: '验证并登录', + backToPassword: '返回密码登录', }, status: { connected: '已连接', @@ -468,6 +477,29 @@ export default { newPassword: '新密码', usernameDesc: '修改控制台登录用户名', passwordDesc: '修改控制台登录密码', + totp: { + title: '双重身份验证', + description: '登录 Web 控制台时要求输入认证器验证码', + serverTime: '服务器时间', + localTime: '本地时间', + clockOffset: '时钟偏差:{seconds} 秒', + clockDriftWarning: '服务器与当前设备的时间相差至少 30 秒,校准时钟前验证码可能无法通过。', + enable: '启用 2FA', + disable: '关闭 2FA', + enableTitle: '设置双重身份验证', + disableTitle: '关闭双重身份验证', + manualSecret: '手动设置密钥', + secretOneTime: '此密钥仅在设置期间显示,请立即添加到认证器。', + enterSixDigitCode: '请输入 6 位认证器验证码', + currentPasswordIncorrect: '当前密码错误', + invalidCode: '认证器验证码错误', + enrollmentExpired: '设置已过期,请重新输入密码开始。', + rateLimited: '错误次数过多,请等待一分钟后重试。', + alreadyEnabled: '双重身份验证已经启用', + notEnabled: '双重身份验证尚未启用', + operationFailed: '无法更新双重身份验证', + loadFailed: '无法加载双重身份验证状态', + }, httpPort: 'HTTP 端口', httpsPort: 'HTTPS 端口', bindAddress: '绑定地址', diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 563adf44..867e8bd8 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { authApi, systemApi } from '@/api' +import { authApi, systemApi, type AuthLoginResponse } from '@/api' export const useAuthStore = defineStore('auth', () => { const user = ref(null) @@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => { const needsSetup = ref(false) const loading = ref(false) const error = ref(null) + let pendingUsername: string | null = null const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null) @@ -41,20 +42,44 @@ export const useAuthStore = defineStore('auth', () => { } } - async function login(username: string, password: string) { + async function beginLogin(username: string, password: string): Promise { loading.value = true error.value = null try { const result = await authApi.login(username, password) - if (result.success) { + if (result.next === 'authenticated') { isAuthenticated.value = true user.value = username - return true + pendingUsername = null } else { - error.value = result.message || 'Login failed' + isAuthenticated.value = false + user.value = null + pendingUsername = username + } + return result + } catch (e) { + error.value = e instanceof Error ? e.message : 'Login failed' + pendingUsername = null + return null + } finally { + loading.value = false + } + } + + async function completeTotpLogin(challengeId: string, code: string) { + loading.value = true + error.value = null + try { + const result = await authApi.loginTotp(challengeId, code) + if (result.next !== 'authenticated') { + error.value = 'Login failed' return false } + isAuthenticated.value = true + user.value = pendingUsername + pendingUsername = null + return true } catch (e) { error.value = e instanceof Error ? e.message : 'Login failed' return false @@ -63,6 +88,16 @@ export const useAuthStore = defineStore('auth', () => { } } + async function login(username: string, password: string) { + const result = await beginLogin(username, password) + return result?.next === 'authenticated' + } + + function cancelPendingLogin() { + pendingUsername = null + error.value = null + } + async function logout() { try { await authApi.logout() @@ -123,6 +158,9 @@ export const useAuthStore = defineStore('auth', () => { isLoggedIn, checkSetupStatus, checkAuth, + beginLogin, + completeTotpLogin, + cancelPendingLogin, login, logout, setup, diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index a81a1e9a..bb11ac9d 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -5,8 +5,6 @@ export interface AuthConfig { session_timeout_secs: number; single_user_allow_multiple_sessions: boolean; - totp_enabled: boolean; - totp_secret?: string; } export interface VideoConfig { diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index ca74dcf9..cdbeed30 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,5 +1,5 @@