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:
@@ -56,6 +56,53 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.
|
||||
return n <= limit, nil
|
||||
}
|
||||
|
||||
// ---- 登录失败锁定(账户级,防撞库:连续失败 N 次锁 window,成功即清)----
|
||||
// 与 A5 的 IP 限流是两道互补闸:IP 限流挡"一个 IP 猛打",账户锁定挡"分布式慢速猜一个号"。
|
||||
// 权衡:per-account 锁定可被人为锁死受害者账户(lockout DoS),故窗口设短(15min)自动解锁 +
|
||||
// 叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
|
||||
const (
|
||||
loginFailWindow = 15 * time.Minute
|
||||
loginFailThreshold = 5
|
||||
)
|
||||
|
||||
func loginFailKey(email string) string { return "sundynix:loginfail:" + email }
|
||||
|
||||
// LoginLocked 报告账户是否因连续登录失败被临时锁定。降级(rdb==nil)返回 false。
|
||||
func (r *Redis) LoginLocked(ctx context.Context, email string) bool {
|
||||
if r.rdb == nil || email == "" {
|
||||
return false
|
||||
}
|
||||
n, err := r.rdb.Get(ctx, loginFailKey(email)).Int()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return n >= loginFailThreshold
|
||||
}
|
||||
|
||||
// NoteLoginFail 记一次登录失败,返回失败后是否已达锁定阈值。首次失败设窗口 TTL
|
||||
// (之后锁定期内因提前拒绝不再自增 → TTL 自最后一次失败起倒计时,到点自动解锁)。
|
||||
func (r *Redis) NoteLoginFail(ctx context.Context, email string) bool {
|
||||
if r.rdb == nil || email == "" {
|
||||
return false
|
||||
}
|
||||
n, err := r.rdb.Incr(ctx, loginFailKey(email)).Result()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if n == 1 {
|
||||
_ = r.rdb.Expire(ctx, loginFailKey(email), loginFailWindow).Err()
|
||||
}
|
||||
return n >= loginFailThreshold
|
||||
}
|
||||
|
||||
// ClearLoginFails 登录成功后清零失败计数。
|
||||
func (r *Redis) ClearLoginFails(ctx context.Context, email string) {
|
||||
if r.rdb == nil || email == "" {
|
||||
return
|
||||
}
|
||||
_ = r.rdb.Del(ctx, loginFailKey(email)).Err()
|
||||
}
|
||||
|
||||
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token)----
|
||||
|
||||
// ---- Token 用量日预算(成本护栏:按用户按天累计 token,供提交前门控)----
|
||||
|
||||
@@ -16,3 +16,16 @@ func TestStreamKey_ChannelIsolation(t *testing.T) {
|
||||
t.Errorf("exec key=%q", ex)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录失败计数 key 独立命名空间,且阈值/窗口是合理防撞库值。
|
||||
func TestLoginFailKey(t *testing.T) {
|
||||
if k := loginFailKey("a@b.com"); k != "sundynix:loginfail:a@b.com" {
|
||||
t.Fatalf("key=%q", k)
|
||||
}
|
||||
if loginFailThreshold < 3 || loginFailThreshold > 10 {
|
||||
t.Fatalf("阈值应在 3–10 合理区间,得 %d", loginFailThreshold)
|
||||
}
|
||||
if loginFailWindow < 5*60*1e9 {
|
||||
t.Fatal("锁定窗口不该太短")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user