feat: 增强SaaS后端功能与安全性

refactor: 重构数据库连接使用PostgreSQL替代SQLite
feat(auth): 增加JWT验证的audience和issuer检查
feat(crypto): 添加AES-256-GCM字段加密支持
feat(api): 集成utoipa实现OpenAPI文档
fix(admin): 修复配置项表单验证逻辑
style: 统一代码格式与类型定义
docs: 更新技术栈文档说明PostgreSQL
This commit is contained in:
iven
2026-03-31 00:12:53 +08:00
parent 4d8d560d1f
commit 44256a511c
177 changed files with 9731 additions and 948 deletions

View File

@@ -94,7 +94,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 +103,10 @@ 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)
// 加密 TOTP 密钥后存储 (但不启用,需要 /verify 确认)
let encrypted_secret = state.field_encryption.encrypt(&setup.secret)?;
sqlx::query("UPDATE accounts SET totp_secret = $1 WHERE id = $2")
.bind(&encrypted_secret)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;
@@ -130,7 +131,7 @@ 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)
@@ -140,14 +141,17 @@ pub async fn verify_totp(
SaasError::InvalidInput("请先调用 /totp/setup 获取密钥".into())
})?;
if !verify_totp_code(&secret, code) {
// 解密 TOTP 密钥(兼容迁移期间的明文数据)
let decrypted_secret = state.field_encryption.decrypt_or_plaintext(&secret);
if !verify_totp_code(&decrypted_secret, code) {
return Err(SaasError::Totp("TOTP 码验证失败".into()));
}
// 验证成功 → 启用 TOTP
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE accounts SET totp_enabled = 1, updated_at = ?1 WHERE id = ?2")
.bind(&now)
let now = chrono::Utc::now();
sqlx::query("UPDATE accounts SET totp_enabled = true, updated_at = $1 WHERE id = $2")
.bind(now)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;
@@ -167,7 +171,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)
@@ -178,9 +182,9 @@ 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")
.bind(&now)
let now = chrono::Utc::now();
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)
.await?;
@@ -190,3 +194,65 @@ pub async fn disable_totp(
Ok(Json(serde_json::json!({"ok": true, "totp_enabled": false, "message": "TOTP 已禁用"})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_totp_secret_format() {
let result = generate_totp_secret("TestIssuer", "user@example.com");
assert!(result.otpauth_uri.starts_with("otpauth://totp/"));
assert!(result.otpauth_uri.contains("secret="));
assert!(result.otpauth_uri.contains("issuer=TestIssuer"));
assert!(result.otpauth_uri.contains("algorithm=SHA1"));
assert!(result.otpauth_uri.contains("digits=6"));
assert!(result.otpauth_uri.contains("period=30"));
// Base32 编码的 20 字节 = 32 字符
assert_eq!(result.secret.len(), 32);
assert_eq!(result.issuer, "TestIssuer");
}
#[test]
fn test_generate_totp_secret_special_chars() {
let result = generate_totp_secret("My App", "user@domain:8080");
// 特殊字符应被 URL 编码
assert!(!result.otpauth_uri.contains("user@domain:8080"));
assert!(result.otpauth_uri.contains("user%40domain"));
}
#[test]
fn test_verify_totp_code_valid() {
// 使用 generate_random_secret 创建合法 secret然后生成并验证码
let secret = generate_random_secret();
let secret_bytes = data_encoding::BASE32.decode(secret.as_bytes()).unwrap();
let totp = totp_rs::TOTP::new(
totp_rs::Algorithm::SHA1, 6, 1, 30, secret_bytes,
).unwrap();
let valid_code = totp.generate(chrono::Utc::now().timestamp() as u64);
assert!(verify_totp_code(&secret, &valid_code));
}
#[test]
fn test_verify_totp_code_invalid() {
let secret = generate_random_secret();
assert!(!verify_totp_code(&secret, "000000"));
assert!(!verify_totp_code(&secret, "999999"));
assert!(!verify_totp_code(&secret, "abcdef"));
}
#[test]
fn test_verify_totp_code_invalid_secret() {
assert!(!verify_totp_code("not-valid-base32!!!", "123456"));
assert!(!verify_totp_code("", "123456"));
assert!(!verify_totp_code("", "123456"));
}
#[test]
fn test_verify_totp_code_empty() {
let secret = "JBSWY3DPEHPK3PXP";
assert!(!verify_totp_code(secret, ""));
assert!(!verify_totp_code(secret, "12345"));
assert!(!verify_totp_code(secret, "1234567"));
}
}