feat(saas): P2 增强 — TOTP 2FA、Relay 重试、配置同步升级

- TOTP 2FA: totp-rs v5.7.1 + data-encoding Base32, setup/verify/disable 流程,
  登录时 TOTP 验证集成, SaasError::Totp 返回 400
- Relay 重试: 指数退避 (base_delay_ms * 2^attempt), 错误分类 (4xx 不重试),
  Admin POST /tasks/:id/retry 端点
- 配置同步: push (客户端覆盖) / merge (SaaS 优先) / diff (只读对比),
  实际写入 config_items 表
- 集成测试: 27 个测试全部通过 (新增 6 个 P2 测试)
- 文档: 更新 SaaS 平台总览 (模块完成度 + API 端点列表)
This commit is contained in:
iven
2026-03-27 17:58:14 +08:00
parent bc12f6899a
commit 452ff45a5f
15 changed files with 876 additions and 68 deletions

View File

@@ -104,6 +104,27 @@ pub async fn login(
return Err(SaasError::AuthError("用户名或密码错误".into()));
}
// TOTP 验证: 如果用户已启用 2FA必须提供有效 TOTP 码
if totp_enabled {
let code = req.totp_code.as_deref()
.ok_or_else(|| SaasError::Totp("此账号已启用双因素认证,请提供 TOTP 码".into()))?;
let (totp_secret,): (Option<String>,) = sqlx::query_as(
"SELECT totp_secret FROM accounts WHERE id = ?1"
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let secret = totp_secret.ok_or_else(|| {
SaasError::Internal("TOTP 已启用但密钥丢失,请联系管理员".into())
})?;
if !super::totp::verify_totp_code(&secret, code) {
return Err(SaasError::Totp("TOTP 码错误或已过期".into()));
}
}
let permissions = get_role_permissions(&state.db, &role).await?;
let config = state.config.read().await;
let token = create_token(

View File

@@ -4,6 +4,7 @@ pub mod jwt;
pub mod password;
pub mod types;
pub mod handlers;
pub mod totp;
use axum::{
extract::{Request, State},
@@ -162,4 +163,7 @@ pub fn protected_routes() -> axum::Router<AppState> {
.route("/api/v1/auth/refresh", post(handlers::refresh))
.route("/api/v1/auth/me", get(handlers::me))
.route("/api/v1/auth/password", put(handlers::change_password))
.route("/api/v1/auth/totp/setup", post(totp::setup_totp))
.route("/api/v1/auth/totp/verify", post(totp::verify_totp))
.route("/api/v1/auth/totp/disable", post(totp::disable_totp))
}

View File

@@ -0,0 +1,192 @@
//! TOTP 双因素认证
use axum::{
extract::{Extension, State},
Json,
};
use crate::state::AppState;
use crate::error::{SaasError, SaasResult};
use crate::auth::types::AuthContext;
use crate::auth::handlers::log_operation;
use serde::{Deserialize, Serialize};
/// TOTP 设置响应
#[derive(Debug, Serialize)]
pub struct TotpSetupResponse {
/// otpauth:// URI用于扫码绑定
pub otpauth_uri: String,
/// Base32 编码的密钥(备用手动输入)
pub secret: String,
/// issuer 名称
pub issuer: String,
}
/// TOTP 验证请求
#[derive(Debug, Deserialize)]
pub struct TotpVerifyRequest {
pub code: String,
}
/// TOTP 禁用请求
#[derive(Debug, Deserialize)]
pub struct TotpDisableRequest {
pub password: String,
}
/// 生成随机 Base32 密钥 (20 字节 = 32 字符 Base32)
fn generate_random_secret() -> String {
use rand::Rng;
let mut bytes = [0u8; 20];
rand::thread_rng().fill(&mut bytes);
data_encoding::BASE32.encode(&bytes)
}
/// Base32 解码
fn base32_decode(data: &str) -> Option<Vec<u8>> {
data_encoding::BASE32.decode(data.as_bytes()).ok()
}
/// 生成 TOTP 密钥并返回 otpauth URI
pub fn generate_totp_secret(issuer: &str, account_name: &str) -> TotpSetupResponse {
let secret = generate_random_secret();
let otpauth_uri = format!(
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits=6&period=30",
urlencoding::encode(issuer),
urlencoding::encode(account_name),
secret,
urlencoding::encode(issuer),
);
TotpSetupResponse {
otpauth_uri,
secret,
issuer: issuer.to_string(),
}
}
/// 验证 TOTP 6 位码
pub fn verify_totp_code(secret: &str, code: &str) -> bool {
let secret_bytes = match base32_decode(secret) {
Some(b) => b,
None => return false,
};
let totp = match totp_rs::TOTP::new(
totp_rs::Algorithm::SHA1,
6, // digits
1, // skew (允许 1 个周期偏差)
30, // step (秒)
secret_bytes,
) {
Ok(t) => t,
Err(_) => return false,
};
totp.check_current(code).unwrap_or(false)
}
/// POST /api/v1/auth/totp/setup
/// 生成 TOTP 密钥并返回 otpauth URI
/// 用户扫码后需要调用 /verify 验证一个码才能激活
pub async fn setup_totp(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<TotpSetupResponse>> {
// 如果已启用 TOTP先清除旧密钥
let (username,): (String,) = sqlx::query_as(
"SELECT username FROM accounts WHERE id = ?1"
)
.bind(&ctx.account_id)
.fetch_one(&state.db)
.await?;
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)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;
log_operation(&state.db, &ctx.account_id, "totp.setup", "account", &ctx.account_id,
None, ctx.client_ip.as_deref()).await?;
Ok(Json(setup))
}
/// POST /api/v1/auth/totp/verify
/// 验证 TOTP 码并启用 2FA
pub async fn verify_totp(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<TotpVerifyRequest>,
) -> SaasResult<Json<serde_json::Value>> {
let code = req.code.trim();
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
return Err(SaasError::InvalidInput("TOTP 码必须是 6 位数字".into()));
}
// 获取存储的密钥
let (totp_secret,): (Option<String>,) = sqlx::query_as(
"SELECT totp_secret FROM accounts WHERE id = ?1"
)
.bind(&ctx.account_id)
.fetch_one(&state.db)
.await?;
let secret = totp_secret.ok_or_else(|| {
SaasError::InvalidInput("请先调用 /totp/setup 获取密钥".into())
})?;
if !verify_totp_code(&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)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;
log_operation(&state.db, &ctx.account_id, "totp.verify", "account", &ctx.account_id,
None, ctx.client_ip.as_deref()).await?;
Ok(Json(serde_json::json!({"ok": true, "totp_enabled": true, "message": "TOTP 已启用"})))
}
/// POST /api/v1/auth/totp/disable
/// 禁用 TOTP (需要密码确认)
pub async fn disable_totp(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<TotpDisableRequest>,
) -> SaasResult<Json<serde_json::Value>> {
// 验证密码
let (password_hash,): (String,) = sqlx::query_as(
"SELECT password_hash FROM accounts WHERE id = ?1"
)
.bind(&ctx.account_id)
.fetch_one(&state.db)
.await?;
if !crate::auth::password::verify_password(&req.password, &password_hash)? {
return Err(SaasError::AuthError("密码错误".into()));
}
// 清除 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)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;
log_operation(&state.db, &ctx.account_id, "totp.disable", "account", &ctx.account_id,
None, ctx.client_ip.as_deref()).await?;
Ok(Json(serde_json::json!({"ok": true, "totp_enabled": false, "message": "TOTP 已禁用"})))
}