feat(auth): 登录账户级失败锁定 + 密码强度 8-72 位(B4)

补上 A5 只做了 IP 限流、没做账户锁定的缺口(审计 B4)。

- 账户级失败锁定:连续登录失败达 5 次即临时锁定 15min(Redis 计数+TTL),锁定期直接拒绝
  不再校验密码,成功登录即清零。与 A5 的 IP 限流互补:IP 限流挡'一个 IP 猛打',账户锁定
  挡'分布式慢速猜一个号'。权衡:per-account 可被人为锁死受害者(lockout DoS),故窗口设短
  自动解锁+叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
- 密码强度:注册从'至少 6 位'提到 8-72 位。上限 72 字节——bcrypt 超 72 字节静默截断,
  不拦会让超长密码实际只用前 72 字节。桌面端注册提示同步改 8-72 位。

存量用户不受影响(只校验注册/改密时的新密码)。build+vet+test 绿;desktop tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 15:56:29 +08:00
parent da9c76f073
commit ebbeed90e9
4 changed files with 74 additions and 4 deletions
+13 -3
View File
@@ -36,8 +36,10 @@ func (h *Handler) Register(c *gin.Context) {
return
}
email := strings.TrimSpace(strings.ToLower(body.Email))
if !strings.Contains(email, "@") || len(body.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱且密码至少 6 位"})
// 密码 8–72 位:8 起步(此前 6 太弱);上限 72 字节——bcrypt 超 72 字节静默截断,
// 不拦会让"超长密码"实际只用前 72 字节,用户以为更安全其实不然。
if !strings.Contains(email, "@") || len(body.Password) < 8 || len(body.Password) > 72 {
c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱,密码 872 位"})
return
}
hash, err := auth.HashPassword(body.Password)
@@ -72,12 +74,20 @@ func (h *Handler) Login(c *gin.Context) {
return
}
email := strings.TrimSpace(strings.ToLower(body.Email))
u, err := h.db.GetUserByEmail(c.Request.Context(), email)
ctx := c.Request.Context()
// 账户级锁定:连续失败达阈值即临时拒绝,不再校验密码(挡分布式慢速撞库;与 A5 IP 限流互补)。
if h.cache.LoginLocked(ctx, email) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "登录失败次数过多,账户已临时锁定,请稍后再试"})
return
}
u, err := h.db.GetUserByEmail(ctx, email)
// 用户不存在与密码错误返回同一文案,避免邮箱枚举。
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, body.Password) {
h.cache.NoteLoginFail(ctx, email) // 记一次失败,累计到阈值即锁定
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
h.cache.ClearLoginFails(ctx, email) // 成功即清零
issueToken(c, u)
}