feat: 添加 TOTP 2FA 功能

This commit is contained in:
mofeng-git
2026-07-17 16:34:06 +08:00
parent fdea52d59d
commit 8bd16df6f8
27 changed files with 1505 additions and 54 deletions

View File

@@ -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 }

View File

@@ -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")

View File

@@ -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};

View File

@@ -39,18 +39,39 @@ impl SessionStore {
}
pub async fn create(&self, user_id: &str) -> Result<Session> {
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<String>)> {
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<Option<Session>> {
@@ -85,6 +106,17 @@ impl SessionStore {
Ok(n)
}
pub async fn delete_all_except(&self, session_id: &str) -> Result<Vec<String>> {
let mut guard = self.inner.write().await;
let revoked: Vec<String> = 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<Vec<String>> {
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(&current.id).await.unwrap();
assert_eq!(revoked, vec![other.id.clone()]);
assert!(sessions.get(&current.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]);
}
}

498
src/auth/two_factor.rs Normal file
View File

@@ -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<Sqlite>,
login_challenges: std::sync::Arc<Mutex<HashMap<String, LoginChallenge>>>,
enrollment_challenges: std::sync::Arc<Mutex<HashMap<String, EnrollmentChallenge>>>,
failures: std::sync::Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
}
impl TwoFactorService {
pub fn new(pool: Pool<Sqlite>) -> Self {
Self {
pool,
login_challenges: Default::default(),
enrollment_challenges: Default::default(),
failures: Default::default(),
}
}
pub async fn is_enabled(&self, user_id: &str) -> Result<bool> {
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<Option<ChallengeInfo>> {
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<String> {
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<EnrollmentInfo> {
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<bool> {
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<Option<Secret>> {
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<TOTP> {
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<bool> {
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());
}
}

View File

@@ -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<String>,
}
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,
}
}
}

View File

@@ -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<Sqlite>) -> Result<AppConfig> {
async fn load_config(pool: &Pool<Sqlite>) -> 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"));
}
}

View File

@@ -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#"

View File

@@ -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),

View File

@@ -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::<ShutdownAction>(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.")

View File

@@ -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<OtgService>,
pub stream_manager: Arc<VideoStreamManager>,
@@ -97,6 +98,7 @@ impl AppState {
config: ConfigStore,
sessions: SessionStore,
users: UserStore,
two_factor: TwoFactorService,
#[cfg(unix)] otg_service: Arc<OtgService>,
stream_manager: Arc<VideoStreamManager>,
webrtc: Arc<WebRtcStreamer>,
@@ -121,6 +123,7 @@ impl AppState {
config,
sessions,
users,
two_factor,
#[cfg(unix)]
otg_service,
stream_manager,

View File

@@ -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]

View File

@@ -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<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
) -> Result<Json<TotpStatusResponse>> {
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<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
Json(req): Json<BeginTotpEnrollmentRequest>,
) -> Result<Json<TotpEnrollmentResponse>> {
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<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
Json(req): Json<ConfirmTotpEnrollmentRequest>,
) -> Result<Json<LoginResponse>> {
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<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
Json(req): Json<DisableTotpRequest>,
) -> Result<Json<LoginResponse>> {
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<AppState>, session: &Session) -> Result<crate::auth::User> {
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<AppState>,
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<AppState>, 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<Arc<AppState>>,

View File

@@ -12,11 +12,26 @@ pub struct LoginResponse {
pub message: Option<String>,
}
#[derive(Serialize)]
pub struct AuthLoginResponse {
pub next: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at_unix_ms: Option<u64>,
}
#[derive(Deserialize)]
pub struct TotpLoginRequest {
pub challenge_id: String,
pub code: String,
}
pub async fn login(
State(state): State<Arc<AppState>>,
cookies: CookieJar,
Json(req): Json<LoginRequest>,
) -> Result<(CookieJar, Json<LoginResponse>)> {
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
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<Arc<AppState>>,
cookies: CookieJar,
Json(req): Json<TotpLoginRequest>,
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
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<AppState>,
cookies: CookieJar,
user_id: &str,
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
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,
}),
))
}

View File

@@ -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<Arc<AppState>>) -> Json<AuthConfig> {
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()))
}

View File

@@ -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;

View File

@@ -38,6 +38,7 @@ pub fn create_router(state: Arc<AppState>) -> 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<AppState>) -> 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))

21
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -15,7 +15,7 @@ const API_BASE = '/api'
export const authApi = {
login: (username: string, password: string) =>
request<{ success: boolean; message?: string }>(
request<AuthLoginResponse>(
'/auth/login',
{
method: 'POST',
@@ -24,6 +24,16 @@ export const authApi = {
{ toastOnError: false },
),
loginTotp: (challengeId: string, code: string) =>
request<AuthLoginResponse>(
'/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<TotpStatusResponse>('/auth/totp'),
beginTotpEnrollment: (currentPassword: string) =>
request<TotpEnrollmentResponse>('/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 {

View File

@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import QrcodeVue from 'qrcode.vue'
import { authApi } from '@/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { AlertTriangle, KeyRound, Loader2, ShieldCheck, ShieldOff } from 'lucide-vue-next'
const { t, locale } = useI18n()
const enabled = ref(false)
const statusLoading = ref(true)
const statusError = ref('')
const now = ref(Date.now())
const serverOffsetMs = ref(0)
const nextSyncAt = ref(0)
const enrollmentOpen = ref(false)
const enrollmentStep = ref<'password' | 'verify'>('password')
const enrollmentLoading = ref(false)
const enrollmentError = ref('')
const enrollmentPassword = ref('')
const enrollmentCode = ref('')
const enrollmentId = ref('')
const enrollmentSecret = ref('')
const enrollmentUri = ref('')
const enrollmentExpiresAt = ref(0)
const disableOpen = ref(false)
const disableLoading = ref(false)
const disableError = ref('')
const disablePassword = ref('')
const disableCode = ref('')
const serverTime = computed(() => formatDate(now.value + serverOffsetMs.value, true))
const localTime = computed(() => formatDate(now.value, false))
const clockOffsetSeconds = computed(() => Math.round(serverOffsetMs.value / 1000))
const clockDriftWarning = computed(() => Math.abs(serverOffsetMs.value) >= 30_000)
let timer: ReturnType<typeof setInterval> | undefined
let statusSyncing = false
function formatDate(timestamp: number, utc: boolean) {
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'medium',
...(utc ? { timeZone: 'UTC' } : {}),
}).format(new Date(timestamp))
}
function applyServerTime(serverTimeUnixMs: number, requestStartedAt: number) {
const localMidpoint = Math.round((requestStartedAt + Date.now()) / 2)
serverOffsetMs.value = serverTimeUnixMs - localMidpoint
nextSyncAt.value = Date.now() + 60_000
}
async function loadStatus() {
if (statusSyncing) return
statusSyncing = true
const startedAt = Date.now()
try {
const status = await authApi.totpStatus()
enabled.value = status.enabled
applyServerTime(status.server_time_unix_ms, startedAt)
statusError.value = ''
} catch {
statusError.value = t('settings.totp.loadFailed')
nextSyncAt.value = Date.now() + 60_000
} finally {
statusSyncing = false
statusLoading.value = false
}
}
function normalizeCode(value: string) {
return value.replace(/\D/g, '').slice(0, 6)
}
function localizedError(error: unknown) {
const raw = error instanceof Error ? error.message : ''
if (raw.includes('Current password is incorrect')) return t('settings.totp.currentPasswordIncorrect')
if (raw.includes('Invalid TOTP code')) return t('settings.totp.invalidCode')
if (raw.includes('enrollment expired')) return t('settings.totp.enrollmentExpired')
if (raw.includes('Too many attempts')) return t('settings.totp.rateLimited')
if (raw.includes('already enabled')) return t('settings.totp.alreadyEnabled')
if (raw.includes('not enabled')) return t('settings.totp.notEnabled')
return raw || t('settings.totp.operationFailed')
}
async function beginEnrollment() {
if (!enrollmentPassword.value) {
enrollmentError.value = t('auth.enterPassword')
return
}
enrollmentLoading.value = true
enrollmentError.value = ''
const startedAt = Date.now()
try {
const result = await authApi.beginTotpEnrollment(enrollmentPassword.value)
enrollmentPassword.value = ''
enrollmentId.value = result.enrollment_id
enrollmentSecret.value = result.secret
enrollmentUri.value = result.otpauth_uri
enrollmentExpiresAt.value = result.expires_at_unix_ms
applyServerTime(result.server_time_unix_ms, startedAt)
enrollmentStep.value = 'verify'
} catch (error) {
enrollmentError.value = localizedError(error)
} finally {
enrollmentLoading.value = false
}
}
async function confirmEnrollment() {
enrollmentCode.value = normalizeCode(enrollmentCode.value)
if (enrollmentCode.value.length !== 6) {
enrollmentError.value = t('settings.totp.enterSixDigitCode')
return
}
enrollmentLoading.value = true
enrollmentError.value = ''
try {
await authApi.confirmTotpEnrollment(enrollmentId.value, enrollmentCode.value)
enabled.value = true
enrollmentOpen.value = false
} catch (error) {
enrollmentError.value = localizedError(error)
enrollmentCode.value = ''
if (error instanceof Error && error.message.includes('enrollment expired')) {
clearEnrollmentSecret()
enrollmentStep.value = 'password'
}
} finally {
enrollmentLoading.value = false
}
}
async function disableTotp() {
disableCode.value = normalizeCode(disableCode.value)
if (!disablePassword.value) {
disableError.value = t('auth.enterPassword')
return
}
if (disableCode.value.length !== 6) {
disableError.value = t('settings.totp.enterSixDigitCode')
return
}
disableLoading.value = true
disableError.value = ''
try {
await authApi.disableTotp(disablePassword.value, disableCode.value)
enabled.value = false
disableOpen.value = false
} catch (error) {
disableError.value = localizedError(error)
disableCode.value = ''
} finally {
disableLoading.value = false
}
}
function clearEnrollmentSecret() {
enrollmentId.value = ''
enrollmentSecret.value = ''
enrollmentUri.value = ''
enrollmentExpiresAt.value = 0
enrollmentCode.value = ''
}
function resetEnrollment() {
clearEnrollmentSecret()
enrollmentPassword.value = ''
enrollmentError.value = ''
enrollmentStep.value = 'password'
}
function resetDisable() {
disablePassword.value = ''
disableCode.value = ''
disableError.value = ''
}
watch(enrollmentOpen, (open) => {
if (!open) resetEnrollment()
})
watch(disableOpen, (open) => {
if (!open) resetDisable()
})
onMounted(async () => {
await loadStatus()
timer = setInterval(() => {
now.value = Date.now()
if (enrollmentId.value && now.value >= enrollmentExpiresAt.value) {
clearEnrollmentSecret()
enrollmentStep.value = 'password'
enrollmentError.value = t('settings.totp.enrollmentExpired')
}
if (now.value >= nextSyncAt.value) void loadStatus()
}, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
resetEnrollment()
resetDisable()
})
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-start justify-between gap-4 space-y-0">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.totp.title') }}</CardTitle>
<CardDescription>{{ t('settings.totp.description') }}</CardDescription>
</div>
<Badge :variant="enabled ? 'default' : 'secondary'">
{{ enabled ? t('common.enabled') : t('common.disabled') }}
</Badge>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-3 text-sm sm:grid-cols-2">
<div class="space-y-1">
<p class="text-xs text-muted-foreground">{{ t('settings.totp.serverTime') }}</p>
<p class="font-mono">{{ serverTime }} UTC</p>
</div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">{{ t('settings.totp.localTime') }}</p>
<p class="font-mono">{{ localTime }}</p>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ t('settings.totp.clockOffset', { seconds: clockOffsetSeconds }) }}
</p>
<Alert v-if="clockDriftWarning" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ t('settings.totp.clockDriftWarning') }}</AlertDescription>
</Alert>
<Alert v-if="statusError" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ statusError }}</AlertDescription>
</Alert>
</CardContent>
<CardFooter class="border-t pt-4 justify-end">
<Button v-if="!enabled" :disabled="statusLoading" @click="enrollmentOpen = true">
<ShieldCheck class="h-4 w-4" />
{{ t('settings.totp.enable') }}
</Button>
<Button v-else variant="destructive" :disabled="statusLoading" @click="disableOpen = true">
<ShieldOff class="h-4 w-4" />
{{ t('settings.totp.disable') }}
</Button>
</CardFooter>
</Card>
<Dialog v-model:open="enrollmentOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.totp.enableTitle') }}</DialogTitle>
</DialogHeader>
<div v-if="enrollmentStep === 'password'" class="space-y-4">
<div class="space-y-2">
<Label for="totp-enrollment-password">{{ t('settings.currentPassword') }}</Label>
<Input id="totp-enrollment-password" v-model="enrollmentPassword" type="password" autocomplete="current-password" />
</div>
</div>
<div v-else class="space-y-4">
<div class="flex justify-center rounded-md border bg-white p-3">
<QrcodeVue :value="enrollmentUri" :size="200" level="M" />
</div>
<div class="space-y-2">
<Label>{{ t('settings.totp.manualSecret') }}</Label>
<Input :model-value="enrollmentSecret" readonly class="font-mono" />
<p class="text-xs text-muted-foreground">{{ t('settings.totp.secretOneTime') }}</p>
</div>
<div class="space-y-2">
<Label for="totp-enrollment-code">{{ t('auth.totpCode') }}</Label>
<div class="relative">
<KeyRound class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="totp-enrollment-code" v-model="enrollmentCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="pl-10 font-mono" @input="enrollmentCode = normalizeCode(enrollmentCode)" />
</div>
</div>
</div>
<Alert v-if="enrollmentError" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ enrollmentError }}</AlertDescription>
</Alert>
<DialogFooter>
<Button variant="outline" :disabled="enrollmentLoading" @click="enrollmentOpen = false">{{ t('common.cancel') }}</Button>
<Button v-if="enrollmentStep === 'password'" :disabled="enrollmentLoading" @click="beginEnrollment">
<Loader2 v-if="enrollmentLoading" class="h-4 w-4 animate-spin" />
{{ t('common.next') }}
</Button>
<Button v-else :disabled="enrollmentLoading" @click="confirmEnrollment">
<Loader2 v-if="enrollmentLoading" class="h-4 w-4 animate-spin" />
{{ t('common.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="disableOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.totp.disableTitle') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="totp-disable-password">{{ t('settings.currentPassword') }}</Label>
<Input id="totp-disable-password" v-model="disablePassword" type="password" autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="totp-disable-code">{{ t('auth.totpCode') }}</Label>
<Input id="totp-disable-code" v-model="disableCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="font-mono" @input="disableCode = normalizeCode(disableCode)" />
</div>
<Alert v-if="disableError" variant="destructive">
<AlertTriangle />
<AlertDescription>{{ disableError }}</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" :disabled="disableLoading" @click="disableOpen = false">{{ t('common.cancel') }}</Button>
<Button variant="destructive" :disabled="disableLoading" @click="disableTotp">
<Loader2 v-if="disableLoading" class="h-4 w-4 animate-spin" />
{{ t('settings.totp.disable') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -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',

View File

@@ -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: '绑定地址',

View File

@@ -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<string | null>(null)
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
const needsSetup = ref(false)
const loading = ref(false)
const error = ref<string | null>(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<AuthLoginResponse | null> {
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,

View File

@@ -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 {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
@@ -11,7 +11,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { AlertCircle, Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
import { AlertCircle, ArrowLeft, KeyRound, Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
const { t } = useI18n()
const router = useRouter()
@@ -23,6 +23,9 @@ function localizedLoginError(raw: string | null): string {
if (!raw) return t('auth.loginFailed')
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
if (raw.includes('Invalid TOTP code')) return t('auth.invalidTotpCode')
if (raw.includes('challenge expired')) return t('auth.totpChallengeExpired')
if (raw.includes('Too many attempts')) return t('auth.totpRateLimited')
return raw
}
@@ -31,6 +34,14 @@ const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
const step = ref<'password' | 'totp'>('password')
const challengeId = ref('')
const challengeExpiresAt = ref(0)
const totpCode = ref('')
function focusTotpInput() {
document.querySelector<HTMLInputElement>('#totp-code')?.focus()
}
async function handleLogin() {
if (!username.value) {
@@ -45,17 +56,67 @@ async function handleLogin() {
loading.value = true
error.value = ''
const success = await authStore.login(username.value, password.value)
const result = await authStore.beginLogin(username.value, password.value)
if (success) {
if (result?.next === 'authenticated') {
const redirect = route.query.redirect as string
router.push(redirect || '/')
} else if (result?.next === 'totp' && result.challenge_id && result.expires_at_unix_ms) {
password.value = ''
challengeId.value = result.challenge_id
challengeExpiresAt.value = result.expires_at_unix_ms
step.value = 'totp'
await nextTick()
focusTotpInput()
} else {
error.value = localizedLoginError(authStore.error)
}
loading.value = false
}
function normalizeTotpCode() {
totpCode.value = totpCode.value.replace(/\D/g, '').slice(0, 6)
}
async function handleTotpLogin() {
normalizeTotpCode()
if (Date.now() >= challengeExpiresAt.value) {
error.value = t('auth.totpChallengeExpired')
returnToPassword(true)
return
}
if (totpCode.value.length !== 6) {
error.value = t('auth.enterTotpCode')
return
}
loading.value = true
error.value = ''
const success = await authStore.completeTotpLogin(challengeId.value, totpCode.value)
if (success) {
const redirect = route.query.redirect as string
router.push(redirect || '/')
} else {
error.value = localizedLoginError(authStore.error)
if (authStore.error?.includes('challenge expired') || authStore.error?.includes('Too many attempts')) {
returnToPassword(true)
} else {
totpCode.value = ''
await nextTick()
focusTotpInput()
}
}
loading.value = false
}
function returnToPassword(keepError = false) {
step.value = 'password'
challengeId.value = ''
challengeExpiresAt.value = 0
totpCode.value = ''
authStore.cancelPendingLogin()
if (!keepError) error.value = ''
}
</script>
<template>
@@ -70,11 +131,11 @@ async function handleLogin() {
<BrandMark size="xl" />
</div>
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
<CardDescription>{{ t('auth.login') }}</CardDescription>
<CardDescription>{{ step === 'password' ? t('auth.login') : t('auth.totpPrompt') }}</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="handleLogin">
<form v-if="step === 'password'" @submit.prevent="handleLogin">
<FieldGroup>
<Field>
<FieldLabel for="username">{{ t('auth.username') }}</FieldLabel>
@@ -149,6 +210,41 @@ async function handleLogin() {
</FieldGroup>
</form>
<form v-else @submit.prevent="handleTotpLogin">
<FieldGroup>
<Field>
<FieldLabel for="totp-code">{{ t('auth.totpCode') }}</FieldLabel>
<div class="relative">
<KeyRound class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="totp-code"
v-model="totpCode"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
class="pl-10 font-mono text-lg"
:placeholder="t('auth.totpCodePlaceholder')"
@input="normalizeTotpCode"
/>
</div>
</Field>
<Alert v-if="error" variant="destructive">
<AlertCircle />
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<Button type="submit" class="w-full" :disabled="loading">
<span v-if="loading">{{ t('common.loading') }}</span>
<span v-else>{{ t('auth.verifyAndLogin') }}</span>
</Button>
<Button type="button" variant="ghost" class="w-full" :disabled="loading" @click="returnToPassword()">
<ArrowLeft class="h-4 w-4" />
{{ t('auth.backToPassword') }}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>

View File

@@ -62,6 +62,7 @@ import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import TerminalDialog from '@/components/TerminalDialog.vue'
import TotpSettingsCard from '@/components/TotpSettingsCard.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -331,8 +332,6 @@ const showPasswords = ref(false)
const authConfig = ref<AuthConfig>({
session_timeout_secs: 3600 * 24,
single_user_allow_multiple_sessions: false,
totp_enabled: false,
totp_secret: undefined,
})
const authConfigLoading = ref(false)
@@ -2868,6 +2867,8 @@ watch(isWindows, () => {
</CardFooter>
</Card>
<TotpSettingsCard />
<Card>
<CardHeader>
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>