chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -8,6 +8,7 @@ use crate::state::AppState;
use crate::error::{SaasError, SaasResult};
use crate::auth::types::AuthContext;
use crate::auth::handlers::log_operation;
use crate::crypto;
use serde::{Deserialize, Serialize};
/// TOTP 设置响应
@@ -46,6 +47,21 @@ fn base32_decode(data: &str) -> Option<Vec<u8>> {
data_encoding::BASE32.decode(data.as_bytes()).ok()
}
/// 加密 TOTP secret (AES-256-GCM随机 nonce)
/// 存储格式: enc:<base64(nonce||ciphertext)>
/// 委托给 crypto::encrypt_value 统一加密
fn encrypt_totp_secret(plaintext: &str, key: &[u8; 32]) -> Result<String, SaasError> {
crate::crypto::encrypt_value(plaintext, key)
.map_err(|e| SaasError::Internal(e.to_string()))
}
/// 解密 TOTP secret (仅支持新格式: 随机 nonce)
/// 旧的固定 nonce 格式应通过启动时迁移转换。
fn decrypt_totp_secret(encrypted: &str, key: &[u8; 32]) -> Result<String, SaasError> {
crate::crypto::decrypt_value(encrypted, key)
.map_err(|e| SaasError::Internal(e.to_string()))
}
/// 生成 TOTP 密钥并返回 otpauth URI
pub fn generate_totp_secret(issuer: &str, account_name: &str) -> TotpSetupResponse {
let secret = generate_random_secret();
@@ -94,7 +110,7 @@ pub async fn setup_totp(
) -> SaasResult<Json<TotpSetupResponse>> {
// 如果已启用 TOTP先清除旧密钥
let (username,): (String,) = sqlx::query_as(
"SELECT username FROM accounts WHERE id = ?1"
"SELECT username FROM accounts WHERE id = $1"
)
.bind(&ctx.account_id)
.fetch_one(&state.db)
@@ -103,9 +119,13 @@ pub async fn setup_totp(
let config = state.config.read().await;
let setup = generate_totp_secret(&config.auth.totp_issuer, &username);
// 存储密钥 (但不启用,需要 /verify 确认)
sqlx::query("UPDATE accounts SET totp_secret = ?1 WHERE id = ?2")
.bind(&setup.secret)
// 加密后存储密钥 (但不启用,需要 /verify 确认)
let enc_key = config.totp_encryption_key()
.map_err(|e| SaasError::Internal(e.to_string()))?;
let encrypted_secret = encrypt_totp_secret(&setup.secret, &enc_key)?;
sqlx::query("UPDATE accounts SET totp_secret = $1 WHERE id = $2")
.bind(&encrypted_secret)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;
@@ -130,23 +150,42 @@ pub async fn verify_totp(
// 获取存储的密钥
let (totp_secret,): (Option<String>,) = sqlx::query_as(
"SELECT totp_secret FROM accounts WHERE id = ?1"
"SELECT totp_secret FROM accounts WHERE id = $1"
)
.bind(&ctx.account_id)
.fetch_one(&state.db)
.await?;
let secret = totp_secret.ok_or_else(|| {
let encrypted_secret = totp_secret.ok_or_else(|| {
SaasError::InvalidInput("请先调用 /totp/setup 获取密钥".into())
})?;
// 解密 secret (兼容旧的明文格式)
let config = state.config.read().await;
let enc_key = config.totp_encryption_key()
.map_err(|e| SaasError::Internal(e.to_string()))?;
let secret = if encrypted_secret.starts_with(crypto::ENCRYPTED_PREFIX) {
decrypt_totp_secret(&encrypted_secret, &enc_key)?
} else {
// 旧格式: 明文存储,需要迁移
encrypted_secret.clone()
};
if !verify_totp_code(&secret, code) {
return Err(SaasError::Totp("TOTP 码验证失败".into()));
}
// 验证成功 → 启用 TOTP
// 验证成功 → 启用 TOTP,同时确保密钥已加密
let final_secret = if encrypted_secret.starts_with(crypto::ENCRYPTED_PREFIX) {
encrypted_secret
} else {
// 迁移: 加密旧明文密钥
encrypt_totp_secret(&secret, &enc_key)?
};
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE accounts SET totp_enabled = 1, updated_at = ?1 WHERE id = ?2")
sqlx::query("UPDATE accounts SET totp_enabled = true, totp_secret = $1, updated_at = $2 WHERE id = $3")
.bind(&final_secret)
.bind(&now)
.bind(&ctx.account_id)
.execute(&state.db)
@@ -167,7 +206,7 @@ pub async fn disable_totp(
) -> SaasResult<Json<serde_json::Value>> {
// 验证密码
let (password_hash,): (String,) = sqlx::query_as(
"SELECT password_hash FROM accounts WHERE id = ?1"
"SELECT password_hash FROM accounts WHERE id = $1"
)
.bind(&ctx.account_id)
.fetch_one(&state.db)
@@ -179,7 +218,7 @@ pub async fn disable_totp(
// 清除 TOTP
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE accounts SET totp_enabled = 0, totp_secret = NULL, updated_at = ?1 WHERE id = ?2")
sqlx::query("UPDATE accounts SET totp_enabled = false, totp_secret = NULL, updated_at = $1 WHERE id = $2")
.bind(&now)
.bind(&ctx.account_id)
.execute(&state.db)
@@ -190,3 +229,14 @@ pub async fn disable_totp(
Ok(Json(serde_json::json!({"ok": true, "totp_enabled": false, "message": "TOTP 已禁用"})))
}
/// 解密 TOTP secret (供 login handler 使用)
/// 返回解密后的明文 secret
pub fn decrypt_totp_for_login(encrypted_secret: &str, enc_key: &[u8; 32]) -> SaasResult<String> {
if encrypted_secret.starts_with(crypto::ENCRYPTED_PREFIX) {
decrypt_totp_secret(encrypted_secret, enc_key)
} else {
// 兼容旧的明文格式
Ok(encrypted_secret.to_string())
}
}