feat: 添加 TOTP 2FA 功能

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

View File

@@ -79,7 +79,7 @@ fn unauthorized_response(message: &str) -> Response {
fn is_public_endpoint(path: &str) -> bool {
matches!(
path,
"/" | "/auth/login" | "/health" | "/setup" | "/setup/init"
"/" | "/auth/login" | "/auth/login/totp" | "/health" | "/setup" | "/setup/init"
) || path.starts_with("/assets/")
|| path.starts_with("/static/")
|| path.ends_with(".js")

View File

@@ -1,9 +1,11 @@
pub mod middleware;
mod password;
mod session;
mod two_factor;
mod user;
pub use middleware::{auth_middleware, SESSION_COOKIE};
pub use password::{hash_password, verify_password};
pub use session::{Session, SessionStore};
pub use two_factor::{server_time_unix_ms, ChallengeInfo, EnrollmentInfo, TwoFactorService};
pub use user::{User, UserStore};

View File

@@ -39,18 +39,39 @@ impl SessionStore {
}
pub async fn create(&self, user_id: &str) -> Result<Session> {
let session = self.new_session(user_id);
let mut guard = self.inner.write().await;
guard.insert(session.id.clone(), session.clone());
Ok(session)
}
pub async fn create_for_login(
&self,
user_id: &str,
allow_multiple_sessions: bool,
) -> Result<(Session, Vec<String>)> {
let session = self.new_session(user_id);
let mut guard = self.inner.write().await;
let revoked = if allow_multiple_sessions {
Vec::new()
} else {
let ids = guard.keys().cloned().collect();
guard.clear();
ids
};
guard.insert(session.id.clone(), session.clone());
Ok((session, revoked))
}
fn new_session(&self, user_id: &str) -> Session {
let now = OffsetDateTime::now_utc();
let session = Session {
Session {
id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
created_at: now,
expires_at: now + self.default_ttl,
data: None,
};
let mut guard = self.inner.write().await;
guard.insert(session.id.clone(), session.clone());
Ok(session)
}
}
pub async fn get(&self, session_id: &str) -> Result<Option<Session>> {
@@ -85,6 +106,17 @@ impl SessionStore {
Ok(n)
}
pub async fn delete_all_except(&self, session_id: &str) -> Result<Vec<String>> {
let mut guard = self.inner.write().await;
let revoked: Vec<String> = guard
.keys()
.filter(|id| id.as_str() != session_id)
.cloned()
.collect();
guard.retain(|id, _| id == session_id);
Ok(revoked)
}
pub async fn list_ids(&self) -> Result<Vec<String>> {
let guard = self.inner.read().await;
Ok(guard.keys().cloned().collect())
@@ -102,3 +134,38 @@ impl SessionStore {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn delete_all_except_preserves_only_current_session() {
let sessions = SessionStore::new(60);
let current = sessions.create("user").await.unwrap();
let other = sessions.create("user").await.unwrap();
let revoked = sessions.delete_all_except(&current.id).await.unwrap();
assert_eq!(revoked, vec![other.id.clone()]);
assert!(sessions.get(&current.id).await.unwrap().is_some());
assert!(sessions.get(&other.id).await.unwrap().is_none());
}
#[tokio::test]
async fn login_creation_applies_session_policy_atomically() {
let sessions = SessionStore::new(60);
let existing = sessions.create("user").await.unwrap();
let (multiple, revoked) = sessions.create_for_login("user", true).await.unwrap();
assert!(revoked.is_empty());
assert!(sessions.get(&existing.id).await.unwrap().is_some());
assert!(sessions.get(&multiple.id).await.unwrap().is_some());
let (single, mut revoked) = sessions.create_for_login("user", false).await.unwrap();
revoked.sort();
let mut expected = vec![existing.id, multiple.id];
expected.sort();
assert_eq!(revoked, expected);
assert_eq!(sessions.list_ids().await.unwrap(), vec![single.id]);
}
}

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

@@ -0,0 +1,498 @@
use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use sqlx::{Pool, Sqlite};
use tokio::sync::Mutex;
use totp_rs::{Algorithm, Secret, TOTP};
use uuid::Uuid;
use crate::error::{AppError, Result};
const LOGIN_TTL: Duration = Duration::from_secs(5 * 60);
const ENROLLMENT_TTL: Duration = Duration::from_secs(10 * 60);
const FAILURE_WINDOW: Duration = Duration::from_secs(60);
const MAX_FAILURES: usize = 5;
struct LoginChallenge {
id: String,
user_id: String,
expires_at: Instant,
expires_at_unix_ms: u64,
failures: usize,
}
struct EnrollmentChallenge {
id: String,
user_id: String,
secret: Secret,
expires_at: Instant,
expires_at_unix_ms: u64,
failures: usize,
}
#[derive(Clone)]
pub struct ChallengeInfo {
pub id: String,
pub expires_at_unix_ms: u64,
}
#[derive(Clone)]
pub struct EnrollmentInfo {
pub id: String,
pub secret: String,
pub otpauth_uri: String,
pub expires_at_unix_ms: u64,
}
#[derive(Clone)]
pub struct TwoFactorService {
pool: Pool<Sqlite>,
login_challenges: std::sync::Arc<Mutex<HashMap<String, LoginChallenge>>>,
enrollment_challenges: std::sync::Arc<Mutex<HashMap<String, EnrollmentChallenge>>>,
failures: std::sync::Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
}
impl TwoFactorService {
pub fn new(pool: Pool<Sqlite>) -> Self {
Self {
pool,
login_challenges: Default::default(),
enrollment_challenges: Default::default(),
failures: Default::default(),
}
}
pub async fn is_enabled(&self, user_id: &str) -> Result<bool> {
let exists: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM user_totp_credentials WHERE user_id = ?1 LIMIT 1")
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(exists.is_some())
}
pub async fn begin_login(&self, user_id: &str) -> Result<Option<ChallengeInfo>> {
if !self.is_enabled(user_id).await? {
return Ok(None);
}
let challenge = LoginChallenge {
id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
expires_at: Instant::now() + LOGIN_TTL,
expires_at_unix_ms: expires_at_unix_ms(LOGIN_TTL),
failures: 0,
};
let info = ChallengeInfo {
id: challenge.id.clone(),
expires_at_unix_ms: challenge.expires_at_unix_ms,
};
self.login_challenges
.lock()
.await
.insert(user_id.to_string(), challenge);
Ok(Some(info))
}
pub async fn complete_login(&self, challenge_id: &str, code: &str) -> Result<String> {
validate_code_format(code)?;
let (user_id, expired) = {
let challenges = self.login_challenges.lock().await;
let challenge = challenges
.values()
.find(|challenge| challenge.id == challenge_id)
.ok_or_else(|| AppError::AuthError("TOTP challenge expired".to_string()))?;
(
challenge.user_id.clone(),
Instant::now() >= challenge.expires_at,
)
};
if expired {
self.login_challenges.lock().await.remove(&user_id);
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
self.enforce_failure_limit(&user_id).await?;
let valid = match self.credential_secret(&user_id).await? {
Some(secret) => verify_at(&secret, code, unix_time_secs())?,
None => {
self.login_challenges.lock().await.remove(&user_id);
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
};
if !valid {
self.record_failure(&user_id).await;
let mut challenges = self.login_challenges.lock().await;
let mut exhausted = false;
if let Some(challenge) = challenges.get_mut(&user_id) {
challenge.failures += 1;
if challenge.failures >= MAX_FAILURES {
exhausted = true;
challenges.remove(&user_id);
}
}
if exhausted {
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let consumed = self
.login_challenges
.lock()
.await
.remove(&user_id)
.is_some_and(|challenge| challenge.id == challenge_id);
if !consumed {
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
Ok(user_id)
}
pub async fn begin_enrollment(
&self,
session_id: &str,
user_id: &str,
username: &str,
) -> Result<EnrollmentInfo> {
if self.is_enabled(user_id).await? {
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
}
let secret = Secret::generate_secret().to_encoded();
let totp = totp(&secret, username)?;
let challenge = EnrollmentChallenge {
id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
secret,
expires_at: Instant::now() + ENROLLMENT_TTL,
expires_at_unix_ms: expires_at_unix_ms(ENROLLMENT_TTL),
failures: 0,
};
let info = EnrollmentInfo {
id: challenge.id.clone(),
secret: challenge.secret.to_string(),
otpauth_uri: totp.get_url(),
expires_at_unix_ms: challenge.expires_at_unix_ms,
};
self.enrollment_challenges
.lock()
.await
.insert(session_id.to_string(), challenge);
Ok(info)
}
pub async fn confirm_enrollment(
&self,
session_id: &str,
user_id: &str,
enrollment_id: &str,
code: &str,
) -> Result<()> {
validate_code_format(code)?;
self.enforce_failure_limit(user_id).await?;
let (secret, expired) = {
let challenges = self.enrollment_challenges.lock().await;
let challenge = challenges
.get(session_id)
.filter(|challenge| challenge.id == enrollment_id && challenge.user_id == user_id)
.ok_or_else(|| AppError::AuthError("TOTP enrollment expired".to_string()))?;
(
challenge.secret.clone(),
Instant::now() >= challenge.expires_at,
)
};
if expired {
self.enrollment_challenges.lock().await.remove(session_id);
return Err(AppError::AuthError("TOTP enrollment expired".to_string()));
}
if !verify_at(&secret, code, unix_time_secs())? {
self.record_failure(user_id).await;
let mut challenges = self.enrollment_challenges.lock().await;
let mut exhausted = false;
if let Some(challenge) = challenges.get_mut(session_id) {
challenge.failures += 1;
if challenge.failures >= MAX_FAILURES {
exhausted = true;
challenges.remove(session_id);
}
}
if exhausted {
return Err(AppError::AuthError("TOTP enrollment expired".to_string()));
}
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let mut transaction = self.pool.begin().await?;
let result =
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
.bind(user_id)
.bind(secret.to_string())
.execute(&mut *transaction)
.await;
match result {
Ok(_) => transaction.commit().await?,
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
}
Err(error) => return Err(error.into()),
}
self.enrollment_challenges.lock().await.remove(session_id);
Ok(())
}
pub async fn disable(&self, user_id: &str, code: &str) -> Result<()> {
validate_code_format(code)?;
self.enforce_failure_limit(user_id).await?;
let secret = self
.credential_secret(user_id)
.await?
.ok_or_else(|| AppError::Conflict("TOTP is not enabled".to_string()))?;
if !verify_at(&secret, code, unix_time_secs())? {
self.record_failure(user_id).await;
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1")
.bind(user_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::Conflict("TOTP is not enabled".to_string()));
}
self.clear_user_challenges(user_id).await;
Ok(())
}
pub async fn disable_without_code(&self, user_id: &str) -> Result<bool> {
let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1")
.bind(user_id)
.execute(&self.pool)
.await?;
self.clear_user_challenges(user_id).await;
Ok(result.rows_affected() > 0)
}
async fn credential_secret(&self, user_id: &str) -> Result<Option<Secret>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT secret FROM user_totp_credentials WHERE user_id = ?1")
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(secret,)| Secret::Encoded(secret)))
}
async fn enforce_failure_limit(&self, user_id: &str) -> Result<()> {
let now = Instant::now();
let mut failures = self.failures.lock().await;
let attempts = failures.entry(user_id.to_string()).or_default();
while attempts
.front()
.is_some_and(|attempt| now.duration_since(*attempt) >= FAILURE_WINDOW)
{
attempts.pop_front();
}
if attempts.len() >= MAX_FAILURES {
return Err(AppError::RateLimited(
"TOTP verification is temporarily limited".to_string(),
));
}
Ok(())
}
async fn record_failure(&self, user_id: &str) {
self.failures
.lock()
.await
.entry(user_id.to_string())
.or_default()
.push_back(Instant::now());
}
async fn clear_user_challenges(&self, user_id: &str) {
self.login_challenges.lock().await.remove(user_id);
self.enrollment_challenges
.lock()
.await
.retain(|_, challenge| challenge.user_id != user_id);
self.failures.lock().await.remove(user_id);
}
}
fn totp(secret: &Secret, account_name: &str) -> Result<TOTP> {
let account_name = account_name.replace(':', "_");
TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret
.to_bytes()
.map_err(|error| AppError::Internal(error.to_string()))?,
Some("One-KVM".to_string()),
account_name,
)
.map_err(|error| AppError::Internal(error.to_string()))
}
fn verify_at(secret: &Secret, code: &str, unix_time: u64) -> Result<bool> {
validate_code_format(code)?;
Ok(totp(secret, "user")?.check(code, unix_time))
}
fn validate_code_format(code: &str) -> Result<()> {
if code.len() != 6 || !code.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(AppError::BadRequest(
"TOTP code must contain exactly 6 digits".to_string(),
));
}
Ok(())
}
pub fn server_time_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn unix_time_secs() -> u64 {
server_time_unix_ms() / 1000
}
fn expires_at_unix_ms(ttl: Duration) -> u64 {
server_time_unix_ms().saturating_add(ttl.as_millis() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::DatabasePool;
use tempfile::tempdir;
async fn test_service() -> (tempfile::TempDir, TwoFactorService, String) {
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("test.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let user_id = "user-1".to_string();
sqlx::query("INSERT INTO users (id, username, password_hash) VALUES (?1, 'admin', 'hash')")
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let service = TwoFactorService::new(db.clone_pool());
(dir, service, user_id)
}
async fn install_known_credential(service: &TwoFactorService, user_id: &str) -> Secret {
let secret = Secret::Raw(b"12345678901234567890".to_vec()).to_encoded();
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
.bind(user_id)
.bind(secret.to_string())
.execute(&service.pool)
.await
.unwrap();
secret
}
#[test]
fn accepts_rfc_vector_and_adjacent_window() {
let secret = Secret::Raw(b"12345678901234567890".to_vec());
assert!(verify_at(&secret, "287082", 59).unwrap());
let code = totp(&secret, "user").unwrap().generate(30);
assert!(verify_at(&secret, &code, 60).unwrap());
}
#[test]
fn rejects_malformed_codes() {
let secret = Secret::Raw(b"12345678901234567890".to_vec());
assert!(verify_at(&secret, "12345", 59).is_err());
assert!(verify_at(&secret, "12345x", 59).is_err());
}
#[tokio::test]
async fn login_challenges_are_replaced_expire_and_are_consumed_once() {
let (_dir, service, user_id) = test_service().await;
let secret = install_known_credential(&service, &user_id).await;
let first = service.begin_login(&user_id).await.unwrap().unwrap();
let second = service.begin_login(&user_id).await.unwrap().unwrap();
let code = totp(&secret, "user").unwrap().generate_current().unwrap();
assert!(service.complete_login(&first.id, &code).await.is_err());
assert_eq!(
service.complete_login(&second.id, &code).await.unwrap(),
user_id
);
assert!(service.complete_login(&second.id, &code).await.is_err());
let expired = service.begin_login(&user_id).await.unwrap().unwrap();
service
.login_challenges
.lock()
.await
.get_mut(&user_id)
.unwrap()
.expires_at = Instant::now() - Duration::from_secs(1);
assert!(service.complete_login(&expired.id, &code).await.is_err());
}
#[tokio::test]
async fn challenge_failure_limit_is_shared_across_new_challenges() {
let (_dir, service, user_id) = test_service().await;
let secret = install_known_credential(&service, &user_id).await;
let valid = totp(&secret, "user").unwrap().generate_current().unwrap();
let invalid = if valid == "000000" {
"000001"
} else {
"000000"
};
let challenge = service.begin_login(&user_id).await.unwrap().unwrap();
for _ in 0..4 {
let error = service
.complete_login(&challenge.id, invalid)
.await
.unwrap_err();
assert!(matches!(error, AppError::AuthError(_)));
}
let error = service
.complete_login(&challenge.id, invalid)
.await
.unwrap_err();
assert!(error.to_string().contains("challenge expired"));
let replacement = service.begin_login(&user_id).await.unwrap().unwrap();
let error = service
.complete_login(&replacement.id, &valid)
.await
.unwrap_err();
assert!(matches!(error, AppError::RateLimited(_)));
}
#[tokio::test]
async fn enrollment_persists_and_disable_is_idempotent_for_cli() {
let (_dir, service, user_id) = test_service().await;
let enrollment = service
.begin_enrollment("session-1", &user_id, "admin")
.await
.unwrap();
let secret = Secret::Encoded(enrollment.secret.clone());
let code = totp(&secret, "admin").unwrap().generate_current().unwrap();
service
.confirm_enrollment("session-1", &user_id, &enrollment.id, &code)
.await
.unwrap();
let restarted = TwoFactorService::new(service.pool.clone());
assert!(restarted.is_enabled(&user_id).await.unwrap());
restarted.disable(&user_id, &code).await.unwrap();
assert!(!restarted.is_enabled(&user_id).await.unwrap());
assert!(!restarted.disable_without_code(&user_id).await.unwrap());
}
}

View File

@@ -7,8 +7,6 @@ use typeshare::typeshare;
pub struct AuthConfig {
pub session_timeout_secs: u32,
pub single_user_allow_multiple_sessions: bool,
pub totp_enabled: bool,
pub totp_secret: Option<String>,
}
impl Default for AuthConfig {
@@ -16,8 +14,6 @@ impl Default for AuthConfig {
Self {
session_timeout_secs: 3600 * 24,
single_user_allow_multiple_sessions: false,
totp_enabled: false,
totp_secret: None,
}
}
}

View File

@@ -27,13 +27,16 @@ impl ConfigStore {
}
pub async fn load(&self) -> Result<()> {
let mut config = Self::load_config(&self.pool).await?;
let (mut config, removed_legacy_totp) = Self::load_config(&self.pool).await?;
config.enforce_invariants();
if removed_legacy_totp {
Self::save_config_to_db(&self.pool, &config).await?;
}
self.cache.store(Arc::new(config));
Ok(())
}
async fn load_config(pool: &Pool<Sqlite>) -> Result<AppConfig> {
async fn load_config(pool: &Pool<Sqlite>) -> Result<(AppConfig, bool)> {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
.fetch_optional(pool)
@@ -41,12 +44,24 @@ impl ConfigStore {
match row {
Some((json,)) => {
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))
let mut value: serde_json::Value =
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?;
let mut removed = false;
if let Some(auth) = value
.get_mut("auth")
.and_then(|value| value.as_object_mut())
{
removed |= auth.remove("totp_enabled").is_some();
removed |= auth.remove("totp_secret").is_some();
}
let config =
serde_json::from_value(value).map_err(|e| AppError::Config(e.to_string()))?;
Ok((config, removed))
}
None => {
let config = AppConfig::default();
Self::save_config_to_db(pool, &config).await?;
Ok(config)
Ok((config, false))
}
}
}
@@ -174,4 +189,35 @@ mod tests {
.is_err());
assert!(!store.get().watchdog.enabled);
}
#[tokio::test]
async fn load_removes_legacy_totp_fields_from_persisted_config() {
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("test.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
let auth = value.get_mut("auth").unwrap().as_object_mut().unwrap();
auth.insert("totp_enabled".to_string(), serde_json::json!(true));
auth.insert(
"totp_secret".to_string(),
serde_json::json!("legacy-secret"),
);
sqlx::query("INSERT INTO config (key, value) VALUES ('app_config', ?1)")
.bind(value.to_string())
.execute(db.pool())
.await
.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
store.load().await.unwrap();
let (persisted,): (String,) =
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
.fetch_one(db.pool())
.await
.unwrap();
assert!(!persisted.contains("totp_enabled"));
assert!(!persisted.contains("totp_secret"));
}
}

View File

@@ -30,6 +30,7 @@ impl DatabasePool {
pub async fn init_schema(&self) -> Result<()> {
self.create_config_table().await?;
self.create_users_table().await?;
self.create_user_totp_credentials_table().await?;
self.create_api_tokens_table().await?;
self.create_wol_history_table().await?;
Ok(())
@@ -86,6 +87,22 @@ impl DatabasePool {
Ok(())
}
async fn create_user_totp_credentials_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS user_totp_credentials (
user_id TEXT PRIMARY KEY,
secret TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_wol_history_table(&self) -> Result<()> {
sqlx::query(
r#"

View File

@@ -14,6 +14,12 @@ pub enum AppError {
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Too many attempts: {0}")]
RateLimited(String),
#[error("Persistence error: {0}")]
Persistence(String),

View File

@@ -14,7 +14,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use one_kvm::atx::AtxController;
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
use one_kvm::auth::{SessionStore, UserStore};
use one_kvm::auth::{SessionStore, TwoFactorService, UserStore};
use one_kvm::computer_use::ComputerUseManager;
use one_kvm::config::{self, AppConfig, ConfigStore};
use one_kvm::db::DatabasePool;
@@ -118,6 +118,8 @@ struct UserCommand {
enum UserAction {
/// Set password for the single local user (interactive terminal prompt)
SetPassword,
/// Disable TOTP for the single local user
DisableTotp,
}
#[tokio::main]
@@ -188,6 +190,7 @@ async fn main() -> anyhow::Result<()> {
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
let user_store = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
@@ -571,6 +574,7 @@ async fn main() -> anyhow::Result<()> {
config_store.clone(),
session_store,
user_store,
two_factor,
#[cfg(unix)]
otg_service,
stream_manager,
@@ -893,10 +897,13 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu
tokio::fs::create_dir_all(&data_dir).await?;
let db = open_database_pool(&data_dir).await?;
let users = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
let sessions = SessionStore::new(0);
match command {
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
CliCommand::User(user) => {
run_user_action(user.action, &users, &sessions, &two_factor).await
}
}
}
@@ -962,12 +969,26 @@ async fn run_user_action(
action: UserAction,
users: &UserStore,
sessions: &SessionStore,
two_factor: &TwoFactorService,
) -> anyhow::Result<()> {
match action {
UserAction::SetPassword => set_user_password(users, sessions).await,
UserAction::DisableTotp => disable_user_totp(users, two_factor).await,
}
}
async fn disable_user_totp(users: &UserStore, two_factor: &TwoFactorService) -> anyhow::Result<()> {
let user = users.single_user().await?.ok_or_else(|| {
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
})?;
if two_factor.disable_without_code(&user.id).await? {
println!("TOTP disabled for user '{}'.", user.username);
} else {
println!("TOTP is already disabled for user '{}'.", user.username);
}
Ok(())
}
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
let user = users.single_user().await?.ok_or_else(|| {
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")

View File

@@ -3,7 +3,7 @@ use tokio::sync::{broadcast, watch, Mutex, RwLock};
use crate::atx::AtxController;
use crate::audio::AudioController;
use crate::auth::{SessionStore, UserStore};
use crate::auth::{SessionStore, TwoFactorService, UserStore};
use crate::computer_use::ComputerUseManager;
use crate::config::ConfigStore;
use crate::db::DatabasePool;
@@ -66,6 +66,7 @@ pub struct AppState {
pub config: ConfigStore,
pub sessions: SessionStore,
pub users: UserStore,
pub two_factor: TwoFactorService,
#[cfg(unix)]
pub otg_service: Arc<OtgService>,
pub stream_manager: Arc<VideoStreamManager>,
@@ -97,6 +98,7 @@ impl AppState {
config: ConfigStore,
sessions: SessionStore,
users: UserStore,
two_factor: TwoFactorService,
#[cfg(unix)] otg_service: Arc<OtgService>,
stream_manager: Arc<VideoStreamManager>,
webrtc: Arc<WebRtcStreamer>,
@@ -121,6 +123,7 @@ impl AppState {
config,
sessions,
users,
two_factor,
#[cfg(unix)]
otg_service,
stream_manager,

View File

@@ -34,6 +34,8 @@ fn status_code(error: &AppError) -> StatusCode {
match error {
AppError::AuthError(_) | AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::INTERNAL_SERVER_ERROR,
@@ -62,6 +64,14 @@ mod tests {
status_code(&AppError::ServiceUnavailable("offline".to_string())),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
status_code(&AppError::Conflict("exists".to_string())),
StatusCode::CONFLICT
);
assert_eq!(
status_code(&AppError::RateLimited("limited".to_string())),
StatusCode::TOO_MANY_REQUESTS
);
}
#[test]

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::auth::server_time_unix_ms;
use crate::state::ShutdownAction;
/// Change password request
@@ -8,6 +9,133 @@ pub struct ChangePasswordRequest {
pub new_password: String,
}
#[derive(Serialize)]
pub struct TotpStatusResponse {
pub enabled: bool,
pub server_time_unix_ms: u64,
}
#[derive(Deserialize)]
pub struct BeginTotpEnrollmentRequest {
pub current_password: String,
}
#[derive(Serialize)]
pub struct TotpEnrollmentResponse {
pub enrollment_id: String,
pub secret: String,
pub otpauth_uri: String,
pub expires_at_unix_ms: u64,
pub server_time_unix_ms: u64,
}
#[derive(Deserialize)]
pub struct ConfirmTotpEnrollmentRequest {
pub enrollment_id: String,
pub code: String,
}
#[derive(Deserialize)]
pub struct DisableTotpRequest {
pub current_password: String,
pub code: String,
}
pub async fn totp_status(
State(state): State<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
) -> Result<Json<TotpStatusResponse>> {
Ok(Json(TotpStatusResponse {
enabled: state.two_factor.is_enabled(&session.user_id).await?,
server_time_unix_ms: server_time_unix_ms(),
}))
}
pub async fn begin_totp_enrollment(
State(state): State<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
Json(req): Json<BeginTotpEnrollmentRequest>,
) -> Result<Json<TotpEnrollmentResponse>> {
let user = authenticated_user(&state, &session).await?;
verify_current_password(&state, &user, &req.current_password).await?;
let enrollment = state
.two_factor
.begin_enrollment(&session.id, &user.id, &user.username)
.await?;
Ok(Json(TotpEnrollmentResponse {
enrollment_id: enrollment.id,
secret: enrollment.secret,
otpauth_uri: enrollment.otpauth_uri,
expires_at_unix_ms: enrollment.expires_at_unix_ms,
server_time_unix_ms: server_time_unix_ms(),
}))
}
pub async fn confirm_totp_enrollment(
State(state): State<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
Json(req): Json<ConfirmTotpEnrollmentRequest>,
) -> Result<Json<LoginResponse>> {
authenticated_user(&state, &session).await?;
state
.two_factor
.confirm_enrollment(&session.id, &session.user_id, &req.enrollment_id, &req.code)
.await?;
revoke_other_sessions(&state, &session.id).await?;
Ok(Json(LoginResponse {
success: true,
message: None,
}))
}
pub async fn disable_totp(
State(state): State<Arc<AppState>>,
axum::Extension(session): axum::Extension<Session>,
Json(req): Json<DisableTotpRequest>,
) -> Result<Json<LoginResponse>> {
let user = authenticated_user(&state, &session).await?;
verify_current_password(&state, &user, &req.current_password).await?;
state.two_factor.disable(&user.id, &req.code).await?;
revoke_other_sessions(&state, &session.id).await?;
Ok(Json(LoginResponse {
success: true,
message: None,
}))
}
async fn authenticated_user(state: &Arc<AppState>, session: &Session) -> Result<crate::auth::User> {
state
.users
.single_user()
.await?
.filter(|user| user.id == session.user_id)
.ok_or_else(|| AppError::AuthError("Invalid session".to_string()))
}
async fn verify_current_password(
state: &Arc<AppState>,
user: &crate::auth::User,
password: &str,
) -> Result<()> {
if state
.users
.verify(&user.username, password)
.await?
.is_none()
{
return Err(AppError::AuthError(
"Current password is incorrect".to_string(),
));
}
Ok(())
}
async fn revoke_other_sessions(state: &Arc<AppState>, current_session_id: &str) -> Result<()> {
let revoked = state.sessions.delete_all_except(current_session_id).await?;
state.remember_revoked_sessions(revoked).await;
Ok(())
}
/// Change current user's password
pub async fn change_password(
State(state): State<Arc<AppState>>,

View File

@@ -12,11 +12,26 @@ pub struct LoginResponse {
pub message: Option<String>,
}
#[derive(Serialize)]
pub struct AuthLoginResponse {
pub next: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at_unix_ms: Option<u64>,
}
#[derive(Deserialize)]
pub struct TotpLoginRequest {
pub challenge_id: String,
pub code: String,
}
pub async fn login(
State(state): State<Arc<AppState>>,
cookies: CookieJar,
Json(req): Json<LoginRequest>,
) -> Result<(CookieJar, Json<LoginResponse>)> {
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
let config = state.config.get();
// Check if system is initialized
@@ -31,15 +46,43 @@ pub async fn login(
.await?
.ok_or_else(|| AppError::AuthError("Invalid username or password".to_string()))?;
if !config.auth.single_user_allow_multiple_sessions {
// Kick existing sessions before creating a new one.
let revoked_ids = state.sessions.list_ids().await?;
state.sessions.delete_all().await?;
state.remember_revoked_sessions(revoked_ids).await;
if let Some(challenge) = state.two_factor.begin_login(&user.id).await? {
return Ok((
cookies,
Json(AuthLoginResponse {
next: "totp",
challenge_id: Some(challenge.id),
expires_at_unix_ms: Some(challenge.expires_at_unix_ms),
}),
));
}
// Create session
let session = state.sessions.create(&user.id).await?;
create_authenticated_session(&state, cookies, &user.id).await
}
pub async fn login_totp(
State(state): State<Arc<AppState>>,
cookies: CookieJar,
Json(req): Json<TotpLoginRequest>,
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
let user_id = state
.two_factor
.complete_login(&req.challenge_id, &req.code)
.await?;
create_authenticated_session(&state, cookies, &user_id).await
}
async fn create_authenticated_session(
state: &Arc<AppState>,
cookies: CookieJar,
user_id: &str,
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
let config = state.config.get();
let (session, revoked_ids) = state
.sessions
.create_for_login(user_id, config.auth.single_user_allow_multiple_sessions)
.await?;
state.remember_revoked_sessions(revoked_ids).await;
// Set session cookie
let cookie = Cookie::build((SESSION_COOKIE, session.id))
@@ -53,9 +96,10 @@ pub async fn login(
Ok((
cookies.add(cookie),
Json(LoginResponse {
success: true,
message: None,
Json(AuthLoginResponse {
next: "authenticated",
challenge_id: None,
expires_at_unix_ms: None,
}),
))
}

View File

@@ -7,11 +7,8 @@ use crate::state::AppState;
use super::types::AuthConfigUpdate;
/// Get auth configuration (sensitive fields are cleared)
pub async fn get_auth_config(State(state): State<Arc<AppState>>) -> Json<AuthConfig> {
let mut auth = state.config.get().auth.clone();
auth.totp_secret = None;
Json(auth)
Json(state.config.get().auth.clone())
}
pub async fn update_auth_config(
@@ -26,7 +23,5 @@ pub async fn update_auth_config(
})
.await?;
let mut auth = state.config.get().auth.clone();
auth.totp_secret = None;
Ok(Json(auth))
Ok(Json(state.config.get().auth.clone()))
}

View File

@@ -54,8 +54,6 @@ use crate::config::AppConfig;
use crate::state::AppState;
fn sanitize_config_for_api(config: &mut AppConfig) {
config.auth.totp_secret = None;
config.stream.turn_password = None;
config.computer_use.api_key = None;

View File

@@ -38,6 +38,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
let public_routes = Router::new()
.route("/health", get(handlers::health_check))
.route("/auth/login", post(handlers::login))
.route("/auth/login/totp", post(handlers::login_totp))
.route("/setup", get(handlers::setup_status))
.route("/setup/init", post(handlers::setup_init));
@@ -48,6 +49,16 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/auth/check", get(handlers::auth_check))
.route("/auth/password", post(handlers::change_password))
.route("/auth/username", post(handlers::change_username))
.route("/auth/totp", get(handlers::totp_status))
.route(
"/auth/totp/enrollment",
post(handlers::begin_totp_enrollment),
)
.route(
"/auth/totp/enrollment/confirm",
post(handlers::confirm_totp_enrollment),
)
.route("/auth/totp/disable", post(handlers::disable_totp))
.route("/devices", get(handlers::list_devices))
// WebSocket endpoint for real-time events
.route("/ws", any(ws_handler))