fix(security): implement all 15 security fixes from penetration test V1
Security audit (2026-03-31): 5 HIGH + 10 MEDIUM issues, all fixed. HIGH: - H1: JWT password_version mechanism (pwv in Claims, middleware verification, auto-increment on password change) - H2: Docker saas port bound to 127.0.0.1 - H3: TOTP encryption key decoupled from JWT secret (production bailout) - H4+H5: Tauri CSP hardened (removed unsafe-inline, restricted connect-src) MEDIUM: - M1: Persistent rate limiting (PostgreSQL rate_limit_events table) - M2: Account lockout (5 failures -> 15min lock) - M3: RFC 5322 email validation with regex - M4: Device registration typed struct with length limits - M5: Provider URL validation on create/update (SSRF prevention) - M6: Legacy TOTP secret migration (fixed nonce -> random nonce) - M7: Legacy frontend crypto migration (static salt -> random salt) - M8+M9: Admin frontend: removed JS token storage, HttpOnly cookie only - M10: Pipeline debug log sanitization (keys only, 100-char truncation) Also: fixed CLAUDE.md Section 12 (was corrupted), added title.rs middleware skeleton, fixed RegisterDeviceRequest visibility.
This commit is contained in:
@@ -80,6 +80,14 @@ pub async fn register(
|
||||
if !req.email.contains('@') || !req.email.contains('.') {
|
||||
return Err(SaasError::InvalidInput("邮箱格式不正确".into()));
|
||||
}
|
||||
// M3: 严格邮箱格式校验
|
||||
static EMAIL_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
let email_re = EMAIL_RE.get_or_init(|| regex::Regex::new(
|
||||
r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$"
|
||||
).unwrap());
|
||||
if !email_re.is_match(&req.email) {
|
||||
return Err(SaasError::InvalidInput("邮箱格式不正确".into()));
|
||||
}
|
||||
if req.password.len() < 8 {
|
||||
return Err(SaasError::InvalidInput("密码至少 8 个字符".into()));
|
||||
}
|
||||
@@ -129,16 +137,25 @@ pub async fn register(
|
||||
|
||||
// 注册成功后自动签发 JWT + Refresh Token
|
||||
let permissions = get_role_permissions(&state.db, &state.role_permissions_cache, &role).await?;
|
||||
// 查询新创建账户的 password_version (默认为 1)
|
||||
let (pwv,): (i32,) = sqlx::query_as(
|
||||
"SELECT password_version FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(&account_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let config = state.config.read().await;
|
||||
let token = create_token(
|
||||
&account_id, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
pwv as u32,
|
||||
)?;
|
||||
let refresh_token = create_refresh_token(
|
||||
&account_id, &role, permissions,
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.refresh_token_hours,
|
||||
pwv as u32,
|
||||
)?;
|
||||
drop(config);
|
||||
|
||||
@@ -173,11 +190,12 @@ pub async fn login(
|
||||
jar: CookieJar,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> SaasResult<(CookieJar, Json<LoginResponse>)> {
|
||||
// 一次查询获取用户信息 + password_hash + totp_secret(合并原来的 3 次查询)
|
||||
// 一次查询获取用户信息 + password_hash + totp_secret + 安全字段(合并原来的 3 次查询)
|
||||
let row: Option<AccountLoginRow> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled,
|
||||
password_hash, totp_secret, created_at, llm_routing
|
||||
password_hash, totp_secret, created_at, llm_routing,
|
||||
password_version, failed_login_count, locked_until
|
||||
FROM accounts WHERE username = $1 OR email = $1"
|
||||
)
|
||||
.bind(&req.username)
|
||||
@@ -190,7 +208,38 @@ pub async fn login(
|
||||
return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", r.status)));
|
||||
}
|
||||
|
||||
// M2: 检查账号是否被临时锁定
|
||||
if let Some(ref locked_until_str) = r.locked_until {
|
||||
if let Ok(locked_time) = chrono::DateTime::parse_from_rfc3339(locked_until_str) {
|
||||
if chrono::Utc::now() < locked_time.with_timezone(&chrono::Utc) {
|
||||
return Err(SaasError::AuthError("账号已被临时锁定,请稍后再试".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verify_password_async(req.password.clone(), r.password_hash.clone()).await? {
|
||||
// M2: 密码错误,递增失败计数
|
||||
let new_count = r.failed_login_count + 1;
|
||||
if new_count >= 5 {
|
||||
// 锁定 15 分钟
|
||||
let locked_until = (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET failed_login_count = $1, locked_until = $2 WHERE id = $3"
|
||||
)
|
||||
.bind(new_count)
|
||||
.bind(&locked_until)
|
||||
.bind(&r.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET failed_login_count = $1 WHERE id = $2"
|
||||
)
|
||||
.bind(new_count)
|
||||
.bind(&r.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
return Err(SaasError::AuthError("用户名或密码错误".into()));
|
||||
}
|
||||
|
||||
@@ -216,20 +265,24 @@ pub async fn login(
|
||||
|
||||
let permissions = get_role_permissions(&state.db, &state.role_permissions_cache, &r.role).await?;
|
||||
let config = state.config.read().await;
|
||||
let pwv = r.password_version as u32;
|
||||
let token = create_token(
|
||||
&r.id, &r.role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
pwv,
|
||||
)?;
|
||||
let refresh_token = create_refresh_token(
|
||||
&r.id, &r.role, permissions,
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.refresh_token_hours,
|
||||
pwv,
|
||||
)?;
|
||||
drop(config);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET last_login_at = $1 WHERE id = $2")
|
||||
// 登录成功: 重置失败计数和锁定状态
|
||||
sqlx::query("UPDATE accounts SET last_login_at = $1, failed_login_count = 0, locked_until = NULL WHERE id = $2")
|
||||
.bind(&now).bind(&r.id)
|
||||
.execute(&state.db).await?;
|
||||
let client_ip = addr.ip().to_string();
|
||||
@@ -296,7 +349,7 @@ pub async fn refresh(
|
||||
.bind(&now).bind(jti)
|
||||
.execute(&state.db).await?;
|
||||
|
||||
// 6. 获取最新角色权限
|
||||
// 6. 获取最新角色权限 + password_version
|
||||
let (role,): (String,) = sqlx::query_as(
|
||||
"SELECT role FROM accounts WHERE id = $1 AND status = 'active'"
|
||||
)
|
||||
@@ -305,6 +358,13 @@ pub async fn refresh(
|
||||
.await?
|
||||
.ok_or_else(|| SaasError::AuthError("账号不存在或已禁用".into()))?;
|
||||
|
||||
let (pwv,): (i32,) = sqlx::query_as(
|
||||
"SELECT password_version FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(&claims.sub)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let permissions = get_role_permissions(&state.db, &state.role_permissions_cache, &role).await?;
|
||||
|
||||
// 7. 创建新的 access token + refresh token
|
||||
@@ -313,11 +373,13 @@ pub async fn refresh(
|
||||
&claims.sub, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
pwv as u32,
|
||||
)?;
|
||||
let new_refresh = create_refresh_token(
|
||||
&claims.sub, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.refresh_token_hours,
|
||||
pwv as u32,
|
||||
)?;
|
||||
drop(config);
|
||||
|
||||
@@ -390,10 +452,10 @@ pub async fn change_password(
|
||||
return Err(SaasError::AuthError("旧密码错误".into()));
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
// 更新密码 + 递增 password_version 使旧 token 失效
|
||||
let new_hash = hash_password_async(req.new_password.clone()).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET password_hash = $1, updated_at = $2 WHERE id = $3")
|
||||
sqlx::query("UPDATE accounts SET password_hash = $1, updated_at = $2, password_version = password_version + 1 WHERE id = $3")
|
||||
.bind(&new_hash)
|
||||
.bind(&now)
|
||||
.bind(&ctx.account_id)
|
||||
|
||||
Reference in New Issue
Block a user