chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user