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

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