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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user