mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 12:41:45 +08:00
feat: 添加 TOTP 2FA 功能
This commit is contained in:
@@ -27,6 +27,7 @@ desktop = [
|
|||||||
"dep:anyhow",
|
"dep:anyhow",
|
||||||
"dep:argon2",
|
"dep:argon2",
|
||||||
"dep:rand",
|
"dep:rand",
|
||||||
|
"dep:totp-rs",
|
||||||
"dep:uuid",
|
"dep:uuid",
|
||||||
"dep:base64",
|
"dep:base64",
|
||||||
"dep:nix",
|
"dep:nix",
|
||||||
@@ -100,6 +101,7 @@ anyhow = { version = "1", optional = true }
|
|||||||
# Authentication
|
# Authentication
|
||||||
argon2 = { version = "0.5", optional = true }
|
argon2 = { version = "0.5", optional = true }
|
||||||
rand = { version = "0.10", optional = true }
|
rand = { version = "0.10", optional = true }
|
||||||
|
totp-rs = { version = "5.7", features = ["gen_secret", "otpauth", "zeroize"], optional = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid = { version = "1", features = ["v4", "serde"], optional = true }
|
uuid = { version = "1", features = ["v4", "serde"], optional = true }
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ fn unauthorized_response(message: &str) -> Response {
|
|||||||
fn is_public_endpoint(path: &str) -> bool {
|
fn is_public_endpoint(path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
path,
|
path,
|
||||||
"/" | "/auth/login" | "/health" | "/setup" | "/setup/init"
|
"/" | "/auth/login" | "/auth/login/totp" | "/health" | "/setup" | "/setup/init"
|
||||||
) || path.starts_with("/assets/")
|
) || path.starts_with("/assets/")
|
||||||
|| path.starts_with("/static/")
|
|| path.starts_with("/static/")
|
||||||
|| path.ends_with(".js")
|
|| path.ends_with(".js")
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
mod password;
|
mod password;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod two_factor;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
pub use middleware::{auth_middleware, SESSION_COOKIE};
|
pub use middleware::{auth_middleware, SESSION_COOKIE};
|
||||||
pub use password::{hash_password, verify_password};
|
pub use password::{hash_password, verify_password};
|
||||||
pub use session::{Session, SessionStore};
|
pub use session::{Session, SessionStore};
|
||||||
|
pub use two_factor::{server_time_unix_ms, ChallengeInfo, EnrollmentInfo, TwoFactorService};
|
||||||
pub use user::{User, UserStore};
|
pub use user::{User, UserStore};
|
||||||
|
|||||||
@@ -39,18 +39,39 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(&self, user_id: &str) -> Result<Session> {
|
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 now = OffsetDateTime::now_utc();
|
||||||
let session = Session {
|
Session {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
expires_at: now + self.default_ttl,
|
expires_at: now + self.default_ttl,
|
||||||
data: None,
|
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>> {
|
pub async fn get(&self, session_id: &str) -> Result<Option<Session>> {
|
||||||
@@ -85,6 +106,17 @@ impl SessionStore {
|
|||||||
Ok(n)
|
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>> {
|
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
||||||
let guard = self.inner.read().await;
|
let guard = self.inner.read().await;
|
||||||
Ok(guard.keys().cloned().collect())
|
Ok(guard.keys().cloned().collect())
|
||||||
@@ -102,3 +134,38 @@ impl SessionStore {
|
|||||||
Ok(())
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
498
src/auth/two_factor.rs
Normal file
498
src/auth/two_factor.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ use typeshare::typeshare;
|
|||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
pub session_timeout_secs: u32,
|
pub session_timeout_secs: u32,
|
||||||
pub single_user_allow_multiple_sessions: bool,
|
pub single_user_allow_multiple_sessions: bool,
|
||||||
pub totp_enabled: bool,
|
|
||||||
pub totp_secret: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthConfig {
|
impl Default for AuthConfig {
|
||||||
@@ -16,8 +14,6 @@ impl Default for AuthConfig {
|
|||||||
Self {
|
Self {
|
||||||
session_timeout_secs: 3600 * 24,
|
session_timeout_secs: 3600 * 24,
|
||||||
single_user_allow_multiple_sessions: false,
|
single_user_allow_multiple_sessions: false,
|
||||||
totp_enabled: false,
|
|
||||||
totp_secret: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,16 @@ impl ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load(&self) -> Result<()> {
|
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();
|
config.enforce_invariants();
|
||||||
|
if removed_legacy_totp {
|
||||||
|
Self::save_config_to_db(&self.pool, &config).await?;
|
||||||
|
}
|
||||||
self.cache.store(Arc::new(config));
|
self.cache.store(Arc::new(config));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_config(pool: &Pool<Sqlite>) -> Result<AppConfig> {
|
async fn load_config(pool: &Pool<Sqlite>) -> Result<(AppConfig, bool)> {
|
||||||
let row: Option<(String,)> =
|
let row: Option<(String,)> =
|
||||||
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
|
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -41,12 +44,24 @@ impl ConfigStore {
|
|||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some((json,)) => {
|
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 => {
|
None => {
|
||||||
let config = AppConfig::default();
|
let config = AppConfig::default();
|
||||||
Self::save_config_to_db(pool, &config).await?;
|
Self::save_config_to_db(pool, &config).await?;
|
||||||
Ok(config)
|
Ok((config, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,4 +189,35 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
assert!(!store.get().watchdog.enabled);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ impl DatabasePool {
|
|||||||
pub async fn init_schema(&self) -> Result<()> {
|
pub async fn init_schema(&self) -> Result<()> {
|
||||||
self.create_config_table().await?;
|
self.create_config_table().await?;
|
||||||
self.create_users_table().await?;
|
self.create_users_table().await?;
|
||||||
|
self.create_user_totp_credentials_table().await?;
|
||||||
self.create_api_tokens_table().await?;
|
self.create_api_tokens_table().await?;
|
||||||
self.create_wol_history_table().await?;
|
self.create_wol_history_table().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -86,6 +87,22 @@ impl DatabasePool {
|
|||||||
Ok(())
|
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<()> {
|
async fn create_wol_history_table(&self) -> Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ pub enum AppError {
|
|||||||
#[error("Bad request: {0}")]
|
#[error("Bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("Too many attempts: {0}")]
|
||||||
|
RateLimited(String),
|
||||||
|
|
||||||
#[error("Persistence error: {0}")]
|
#[error("Persistence error: {0}")]
|
||||||
Persistence(String),
|
Persistence(String),
|
||||||
|
|
||||||
|
|||||||
25
src/main.rs
25
src/main.rs
@@ -14,7 +14,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|||||||
|
|
||||||
use one_kvm::atx::AtxController;
|
use one_kvm::atx::AtxController;
|
||||||
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
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::computer_use::ComputerUseManager;
|
||||||
use one_kvm::config::{self, AppConfig, ConfigStore};
|
use one_kvm::config::{self, AppConfig, ConfigStore};
|
||||||
use one_kvm::db::DatabasePool;
|
use one_kvm::db::DatabasePool;
|
||||||
@@ -118,6 +118,8 @@ struct UserCommand {
|
|||||||
enum UserAction {
|
enum UserAction {
|
||||||
/// Set password for the single local user (interactive terminal prompt)
|
/// Set password for the single local user (interactive terminal prompt)
|
||||||
SetPassword,
|
SetPassword,
|
||||||
|
/// Disable TOTP for the single local user
|
||||||
|
DisableTotp,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -188,6 +190,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||||
|
|
||||||
let user_store = UserStore::new(db.clone_pool());
|
let user_store = UserStore::new(db.clone_pool());
|
||||||
|
let two_factor = TwoFactorService::new(db.clone_pool());
|
||||||
|
|
||||||
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
|
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
|
||||||
|
|
||||||
@@ -571,6 +574,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
config_store.clone(),
|
config_store.clone(),
|
||||||
session_store,
|
session_store,
|
||||||
user_store,
|
user_store,
|
||||||
|
two_factor,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
otg_service,
|
otg_service,
|
||||||
stream_manager,
|
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?;
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
let db = open_database_pool(&data_dir).await?;
|
let db = open_database_pool(&data_dir).await?;
|
||||||
let users = UserStore::new(db.clone_pool());
|
let users = UserStore::new(db.clone_pool());
|
||||||
|
let two_factor = TwoFactorService::new(db.clone_pool());
|
||||||
let sessions = SessionStore::new(0);
|
let sessions = SessionStore::new(0);
|
||||||
|
|
||||||
match command {
|
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,
|
action: UserAction,
|
||||||
users: &UserStore,
|
users: &UserStore,
|
||||||
sessions: &SessionStore,
|
sessions: &SessionStore,
|
||||||
|
two_factor: &TwoFactorService,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
match action {
|
match action {
|
||||||
UserAction::SetPassword => set_user_password(users, sessions).await,
|
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<()> {
|
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
|
||||||
let user = users.single_user().await?.ok_or_else(|| {
|
let user = users.single_user().await?.ok_or_else(|| {
|
||||||
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
|
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
|||||||
|
|
||||||
use crate::atx::AtxController;
|
use crate::atx::AtxController;
|
||||||
use crate::audio::AudioController;
|
use crate::audio::AudioController;
|
||||||
use crate::auth::{SessionStore, UserStore};
|
use crate::auth::{SessionStore, TwoFactorService, UserStore};
|
||||||
use crate::computer_use::ComputerUseManager;
|
use crate::computer_use::ComputerUseManager;
|
||||||
use crate::config::ConfigStore;
|
use crate::config::ConfigStore;
|
||||||
use crate::db::DatabasePool;
|
use crate::db::DatabasePool;
|
||||||
@@ -66,6 +66,7 @@ pub struct AppState {
|
|||||||
pub config: ConfigStore,
|
pub config: ConfigStore,
|
||||||
pub sessions: SessionStore,
|
pub sessions: SessionStore,
|
||||||
pub users: UserStore,
|
pub users: UserStore,
|
||||||
|
pub two_factor: TwoFactorService,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub otg_service: Arc<OtgService>,
|
pub otg_service: Arc<OtgService>,
|
||||||
pub stream_manager: Arc<VideoStreamManager>,
|
pub stream_manager: Arc<VideoStreamManager>,
|
||||||
@@ -97,6 +98,7 @@ impl AppState {
|
|||||||
config: ConfigStore,
|
config: ConfigStore,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
users: UserStore,
|
users: UserStore,
|
||||||
|
two_factor: TwoFactorService,
|
||||||
#[cfg(unix)] otg_service: Arc<OtgService>,
|
#[cfg(unix)] otg_service: Arc<OtgService>,
|
||||||
stream_manager: Arc<VideoStreamManager>,
|
stream_manager: Arc<VideoStreamManager>,
|
||||||
webrtc: Arc<WebRtcStreamer>,
|
webrtc: Arc<WebRtcStreamer>,
|
||||||
@@ -121,6 +123,7 @@ impl AppState {
|
|||||||
config,
|
config,
|
||||||
sessions,
|
sessions,
|
||||||
users,
|
users,
|
||||||
|
two_factor,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
otg_service,
|
otg_service,
|
||||||
stream_manager,
|
stream_manager,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ fn status_code(error: &AppError) -> StatusCode {
|
|||||||
match error {
|
match error {
|
||||||
AppError::AuthError(_) | AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
AppError::AuthError(_) | AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -62,6 +64,14 @@ mod tests {
|
|||||||
status_code(&AppError::ServiceUnavailable("offline".to_string())),
|
status_code(&AppError::ServiceUnavailable("offline".to_string())),
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::server_time_unix_ms;
|
||||||
use crate::state::ShutdownAction;
|
use crate::state::ShutdownAction;
|
||||||
|
|
||||||
/// Change password request
|
/// Change password request
|
||||||
@@ -8,6 +9,133 @@ pub struct ChangePasswordRequest {
|
|||||||
pub new_password: String,
|
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
|
/// Change current user's password
|
||||||
pub async fn change_password(
|
pub async fn change_password(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
|||||||
@@ -12,11 +12,26 @@ pub struct LoginResponse {
|
|||||||
pub message: Option<String>,
|
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(
|
pub async fn login(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
cookies: CookieJar,
|
cookies: CookieJar,
|
||||||
Json(req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
) -> Result<(CookieJar, Json<LoginResponse>)> {
|
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
|
|
||||||
// Check if system is initialized
|
// Check if system is initialized
|
||||||
@@ -31,15 +46,43 @@ pub async fn login(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::AuthError("Invalid username or password".to_string()))?;
|
.ok_or_else(|| AppError::AuthError("Invalid username or password".to_string()))?;
|
||||||
|
|
||||||
if !config.auth.single_user_allow_multiple_sessions {
|
if let Some(challenge) = state.two_factor.begin_login(&user.id).await? {
|
||||||
// Kick existing sessions before creating a new one.
|
return Ok((
|
||||||
let revoked_ids = state.sessions.list_ids().await?;
|
cookies,
|
||||||
state.sessions.delete_all().await?;
|
Json(AuthLoginResponse {
|
||||||
state.remember_revoked_sessions(revoked_ids).await;
|
next: "totp",
|
||||||
|
challenge_id: Some(challenge.id),
|
||||||
|
expires_at_unix_ms: Some(challenge.expires_at_unix_ms),
|
||||||
|
}),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
create_authenticated_session(&state, cookies, &user.id).await
|
||||||
let session = state.sessions.create(&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
|
// Set session cookie
|
||||||
let cookie = Cookie::build((SESSION_COOKIE, session.id))
|
let cookie = Cookie::build((SESSION_COOKIE, session.id))
|
||||||
@@ -53,9 +96,10 @@ pub async fn login(
|
|||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
cookies.add(cookie),
|
cookies.add(cookie),
|
||||||
Json(LoginResponse {
|
Json(AuthLoginResponse {
|
||||||
success: true,
|
next: "authenticated",
|
||||||
message: None,
|
challenge_id: None,
|
||||||
|
expires_at_unix_ms: None,
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
use super::types::AuthConfigUpdate;
|
use super::types::AuthConfigUpdate;
|
||||||
|
|
||||||
/// Get auth configuration (sensitive fields are cleared)
|
|
||||||
pub async fn get_auth_config(State(state): State<Arc<AppState>>) -> Json<AuthConfig> {
|
pub async fn get_auth_config(State(state): State<Arc<AppState>>) -> Json<AuthConfig> {
|
||||||
let mut auth = state.config.get().auth.clone();
|
Json(state.config.get().auth.clone())
|
||||||
auth.totp_secret = None;
|
|
||||||
Json(auth)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_auth_config(
|
pub async fn update_auth_config(
|
||||||
@@ -26,7 +23,5 @@ pub async fn update_auth_config(
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut auth = state.config.get().auth.clone();
|
Ok(Json(state.config.get().auth.clone()))
|
||||||
auth.totp_secret = None;
|
|
||||||
Ok(Json(auth))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ use crate::config::AppConfig;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
fn sanitize_config_for_api(config: &mut AppConfig) {
|
fn sanitize_config_for_api(config: &mut AppConfig) {
|
||||||
config.auth.totp_secret = None;
|
|
||||||
|
|
||||||
config.stream.turn_password = None;
|
config.stream.turn_password = None;
|
||||||
config.computer_use.api_key = None;
|
config.computer_use.api_key = None;
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/health", get(handlers::health_check))
|
.route("/health", get(handlers::health_check))
|
||||||
.route("/auth/login", post(handlers::login))
|
.route("/auth/login", post(handlers::login))
|
||||||
|
.route("/auth/login/totp", post(handlers::login_totp))
|
||||||
.route("/setup", get(handlers::setup_status))
|
.route("/setup", get(handlers::setup_status))
|
||||||
.route("/setup/init", post(handlers::setup_init));
|
.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/check", get(handlers::auth_check))
|
||||||
.route("/auth/password", post(handlers::change_password))
|
.route("/auth/password", post(handlers::change_password))
|
||||||
.route("/auth/username", post(handlers::change_username))
|
.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))
|
.route("/devices", get(handlers::list_devices))
|
||||||
// WebSocket endpoint for real-time events
|
// WebSocket endpoint for real-time events
|
||||||
.route("/ws", any(ws_handler))
|
.route("/ws", any(ws_handler))
|
||||||
|
|||||||
21
web/package-lock.json
generated
21
web/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"lucide-vue-next": "^0.556.0",
|
"lucide-vue-next": "^0.556.0",
|
||||||
"opus-decoder": "^0.7.11",
|
"opus-decoder": "^0.7.11",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"qrcode.vue": "^3.10.0",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"simple-keyboard": "^3.8.163",
|
"simple-keyboard": "^3.8.163",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
@@ -85,6 +86,17 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
@@ -2556,6 +2568,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.10.1",
|
"version": "2.10.1",
|
||||||
"resolved": "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz",
|
"resolved": "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"lucide-vue-next": "^0.556.0",
|
"lucide-vue-next": "^0.556.0",
|
||||||
"opus-decoder": "^0.7.11",
|
"opus-decoder": "^0.7.11",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"qrcode.vue": "^3.10.0",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"simple-keyboard": "^3.8.163",
|
"simple-keyboard": "^3.8.163",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const API_BASE = '/api'
|
|||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
request<{ success: boolean; message?: string }>(
|
request<AuthLoginResponse>(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -24,6 +24,16 @@ export const authApi = {
|
|||||||
{ toastOnError: false },
|
{ toastOnError: false },
|
||||||
),
|
),
|
||||||
|
|
||||||
|
loginTotp: (challengeId: string, code: string) =>
|
||||||
|
request<AuthLoginResponse>(
|
||||||
|
'/auth/login/totp',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ challenge_id: challengeId, code }),
|
||||||
|
},
|
||||||
|
{ toastOnError: false },
|
||||||
|
),
|
||||||
|
|
||||||
logout: () =>
|
logout: () =>
|
||||||
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
||||||
|
|
||||||
@@ -41,6 +51,46 @@ export const authApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, current_password: currentPassword }),
|
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 {
|
export interface NetworkAddress {
|
||||||
|
|||||||
338
web/src/components/TotpSettingsCard.vue
Normal file
338
web/src/components/TotpSettingsCard.vue
Normal 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>
|
||||||
@@ -74,6 +74,15 @@ export default {
|
|||||||
forgotPassword: 'Forgot password',
|
forgotPassword: 'Forgot password',
|
||||||
forgotPasswordHint:
|
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.',
|
'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: {
|
status: {
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
@@ -469,6 +478,29 @@ export default {
|
|||||||
newPassword: 'New Password',
|
newPassword: 'New Password',
|
||||||
usernameDesc: 'Change the console login username',
|
usernameDesc: 'Change the console login username',
|
||||||
passwordDesc: 'Change the console login password',
|
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',
|
httpPort: 'HTTP Port',
|
||||||
httpsPort: 'HTTPS Port',
|
httpsPort: 'HTTPS Port',
|
||||||
bindAddress: 'Bind Address',
|
bindAddress: 'Bind Address',
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ export default {
|
|||||||
forgotPassword: '忘记密码',
|
forgotPassword: '忘记密码',
|
||||||
forgotPasswordHint:
|
forgotPasswordHint:
|
||||||
'忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password,按提示输入并确认新密码即可重置。',
|
'忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password,按提示输入并确认新密码即可重置。',
|
||||||
|
totpPrompt: '请输入认证器应用中的验证码',
|
||||||
|
totpCode: '认证器验证码',
|
||||||
|
totpCodePlaceholder: '6 位验证码',
|
||||||
|
enterTotpCode: '请输入 6 位认证器验证码',
|
||||||
|
invalidTotpCode: '认证器验证码错误',
|
||||||
|
totpChallengeExpired: '本次登录验证已过期,请重新输入密码。',
|
||||||
|
totpRateLimited: '错误次数过多,请等待一分钟后重新登录。',
|
||||||
|
verifyAndLogin: '验证并登录',
|
||||||
|
backToPassword: '返回密码登录',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
@@ -468,6 +477,29 @@ export default {
|
|||||||
newPassword: '新密码',
|
newPassword: '新密码',
|
||||||
usernameDesc: '修改控制台登录用户名',
|
usernameDesc: '修改控制台登录用户名',
|
||||||
passwordDesc: '修改控制台登录密码',
|
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 端口',
|
httpPort: 'HTTP 端口',
|
||||||
httpsPort: 'HTTPS 端口',
|
httpsPort: 'HTTPS 端口',
|
||||||
bindAddress: '绑定地址',
|
bindAddress: '绑定地址',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { authApi, systemApi } from '@/api'
|
import { authApi, systemApi, type AuthLoginResponse } from '@/api'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<string | null>(null)
|
const user = ref<string | null>(null)
|
||||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const needsSetup = ref(false)
|
const needsSetup = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
let pendingUsername: string | null = null
|
||||||
|
|
||||||
const isLoggedIn = computed(() => isAuthenticated.value && user.value !== 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
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authApi.login(username, password)
|
const result = await authApi.login(username, password)
|
||||||
if (result.success) {
|
if (result.next === 'authenticated') {
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
user.value = username
|
user.value = username
|
||||||
return true
|
pendingUsername = null
|
||||||
} else {
|
} 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
|
return false
|
||||||
}
|
}
|
||||||
|
isAuthenticated.value = true
|
||||||
|
user.value = pendingUsername
|
||||||
|
pendingUsername = null
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Login failed'
|
error.value = e instanceof Error ? e.message : 'Login failed'
|
||||||
return false
|
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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
@@ -123,6 +158,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
checkSetupStatus,
|
checkSetupStatus,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
|
beginLogin,
|
||||||
|
completeTotpLogin,
|
||||||
|
cancelPendingLogin,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
setup,
|
setup,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
session_timeout_secs: number;
|
session_timeout_secs: number;
|
||||||
single_user_allow_multiple_sessions: boolean;
|
single_user_allow_multiple_sessions: boolean;
|
||||||
totp_enabled: boolean;
|
|
||||||
totp_secret?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoConfig {
|
export interface VideoConfig {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
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 { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
import BrandMark from '@/components/BrandMark.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 { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -23,6 +23,9 @@ function localizedLoginError(raw: string | null): string {
|
|||||||
if (!raw) return t('auth.loginFailed')
|
if (!raw) return t('auth.loginFailed')
|
||||||
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
|
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
|
||||||
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
|
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
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,14 @@ const password = ref('')
|
|||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
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() {
|
async function handleLogin() {
|
||||||
if (!username.value) {
|
if (!username.value) {
|
||||||
@@ -45,17 +56,67 @@ async function handleLogin() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
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
|
const redirect = route.query.redirect as string
|
||||||
router.push(redirect || '/')
|
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 {
|
} else {
|
||||||
error.value = localizedLoginError(authStore.error)
|
error.value = localizedLoginError(authStore.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -70,11 +131,11 @@ async function handleLogin() {
|
|||||||
<BrandMark size="xl" />
|
<BrandMark size="xl" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form @submit.prevent="handleLogin">
|
<form v-if="step === 'password'" @submit.prevent="handleLogin">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="username">{{ t('auth.username') }}</FieldLabel>
|
<FieldLabel for="username">{{ t('auth.username') }}</FieldLabel>
|
||||||
@@ -149,6 +210,41 @@ async function handleLogin() {
|
|||||||
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
|||||||
import AppLayout from '@/components/AppLayout.vue'
|
import AppLayout from '@/components/AppLayout.vue'
|
||||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
import TerminalDialog from '@/components/TerminalDialog.vue'
|
import TerminalDialog from '@/components/TerminalDialog.vue'
|
||||||
|
import TotpSettingsCard from '@/components/TotpSettingsCard.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -331,8 +332,6 @@ const showPasswords = ref(false)
|
|||||||
const authConfig = ref<AuthConfig>({
|
const authConfig = ref<AuthConfig>({
|
||||||
session_timeout_secs: 3600 * 24,
|
session_timeout_secs: 3600 * 24,
|
||||||
single_user_allow_multiple_sessions: false,
|
single_user_allow_multiple_sessions: false,
|
||||||
totp_enabled: false,
|
|
||||||
totp_secret: undefined,
|
|
||||||
})
|
})
|
||||||
const authConfigLoading = ref(false)
|
const authConfigLoading = ref(false)
|
||||||
|
|
||||||
@@ -2868,6 +2867,8 @@ watch(isWindows, () => {
|
|||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<TotpSettingsCard />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user