fix: 前端深度审计全量修复 — 安全/功能/代码质量
严重 BUG 修复: - 修复 Token 过期后 hash 重定向导致无法跳转登录页 - 修复文章编辑器新建后提交审核使用错误 ID 安全加固: - HTML 清理函数替换为 ammonia 专业库(替代自定义解析器) - 文件上传添加 magic bytes 校验(防 Content-Type 伪造) - 登录添加账户级失败锁定(5次失败→15分钟锁定) - 审计日志 9 个关键更新操作补充变更前后值(with_changes) 功能缺陷修复: - 登录/登出时清理 API 缓存(防多账户数据污染) - 文章编辑器上传改用统一 HTTP 客户端(自动 token 刷新) - 添加全局 HTTP 错误处理和后端错误消息展示 - PrivateRoute 增加路由级权限检查(系统管理页面) - 健康数据三个 Tab 添加编辑/删除功能 - 预约创建增加排班可用性校验提示 - 医生详情 API 返回解密后的原始执照号 代码清理: - 删除未使用的 auth.ts refresh() 函数 - 删除重复的 AuthGuard.tsx 组件 - 删除未使用的 getHealthSummary API
This commit is contained in:
@@ -19,6 +19,10 @@ struct RateLimitResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// 账户锁定配置。
|
||||
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
|
||||
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
|
||||
|
||||
/// 限流参数(预留配置化扩展)。
|
||||
#[allow(dead_code)]
|
||||
pub struct RateLimitConfig {
|
||||
@@ -162,6 +166,133 @@ async fn apply_rate_limit(
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// 账户级登录锁定中间件。
|
||||
///
|
||||
/// 针对登录接口(POST /api/v1/auth/login),在 IP 限流之前执行:
|
||||
/// 1. 解析请求体提取 username
|
||||
/// 2. 检查 Redis 中该 username 的失败次数
|
||||
/// 3. 超过阈值(5次)则拒绝请求
|
||||
/// 4. 观察响应状态码:401 递增失败计数,200 清除计数
|
||||
pub async fn account_lockout_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let avail = redis_avail();
|
||||
|
||||
// Redis 不可达时 fail-open:放行请求
|
||||
if !avail.should_try().await {
|
||||
tracing::warn!("Redis 不可达,fail-open 账户锁定检查放行");
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// 获取 Redis 连接
|
||||
let mut conn = match state.redis.get_multiplexed_async_connection().await {
|
||||
Ok(c) => {
|
||||
avail.mark_ok();
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis 连接失败,fail-open 账户锁定检查放行");
|
||||
avail.mark_failed().await;
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 读取请求体以提取 username
|
||||
let (parts, body) = req.into_parts();
|
||||
let bytes = match axum::body::to_bytes(body, 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "读取登录请求体失败,放行");
|
||||
// 无法读取 body,重建请求放行
|
||||
let req = Request::from_parts(parts, Body::from(Vec::new()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 解析 username
|
||||
let username = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.ok()
|
||||
.and_then(|v| v.get("username")?.as_str().map(|s| s.to_string()));
|
||||
|
||||
let username = match username {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
// 无法解析 username,用原始 body 重建请求放行
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查账户锁定状态
|
||||
let lockout_key = format!("login_fail:{}", username);
|
||||
let fail_count: i64 = conn.get(&lockout_key).await.unwrap_or(0);
|
||||
|
||||
if fail_count >= ACCOUNT_LOCKOUT_MAX_FAILURES {
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
fail_count = fail_count,
|
||||
"账户已被临时锁定"
|
||||
);
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "账户已被临时锁定,请15分钟后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 用原始 body 重建请求,转发到 handler
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
let response = next.run(req).await;
|
||||
|
||||
// 观察响应状态码
|
||||
let status = response.status();
|
||||
let (parts, body) = response.into_parts();
|
||||
|
||||
// 需要读取 body 以重建响应(因为 into_parts 消费了 body)
|
||||
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
// 登录失败:递增失败计数
|
||||
let new_count: i64 = match redis::cmd("INCR")
|
||||
.arg(&lockout_key)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败计数失败");
|
||||
// 即使计数失败,也返回原始 401 响应
|
||||
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次失败时设置 TTL
|
||||
if new_count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&lockout_key, ACCOUNT_LOCKOUT_TTL_SECS).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
username = %username,
|
||||
fail_count = new_count,
|
||||
remaining = ACCOUNT_LOCKOUT_MAX_FAILURES - new_count,
|
||||
"登录失败,递增失败计数"
|
||||
);
|
||||
} else if status.is_success() {
|
||||
// 登录成功:清除失败计数
|
||||
let _: Result<(), _> = conn.del(&lockout_key).await;
|
||||
tracing::info!(username = %username, "登录成功,清除失败计数");
|
||||
}
|
||||
|
||||
// 重建并返回原始响应
|
||||
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
|
||||
resp
|
||||
}
|
||||
|
||||
/// 从请求头中提取客户端 IP。
|
||||
fn extract_client_ip(headers: &axum::http::HeaderMap) -> String {
|
||||
headers
|
||||
|
||||
Reference in New Issue
Block a user