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:
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
192
crates/zclaw-saas/src/auth/totp.rs
Normal file
192
crates/zclaw-saas/src/auth/totp.rs
Normal 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 已禁用"})))
|
||||
}
|
||||
Reference in New Issue
Block a user