直接用time.afterfunc做账号锁定会失效,因为gin并发处理请求时共享变量导致计数错乱,且定时器可能被gc回收;必须将状态存于redis等持久化载体,用incr+expire原子操作实现带ttl的失败计数。

为什么直接用 time.AfterFunc 做账号锁定会失效
因为 Gin 是并发处理请求的,多个请求共享同一个变量(比如失败次数计数器),没加锁会导致计数错乱;更关键的是,time.AfterFunc 的定时器在 handler 返回后可能被 GC 回收,尤其在短连接或中间件提前退出时,锁定逻辑根本不会触发。
- 不要把计数器存在局部变量或 struct 字段里——每次请求都是新实例
- 不要依赖 handler 内部启一个 goroutine 然后 sleep —— 请求结束、goroutine 被杀,锁不生效
- 必须把状态存到外部可持久化的载体中,比如 Redis 或带 TTL 的内存 map(如
sync.Map+ 定时清理)
用 Redis 实现带自动过期的失败计数
Redis 的 INCR 和 EXPIRE 原子组合是最稳妥的选择:先递增失败次数,再设置过期时间(仅当 key 不存在时设过期),避免重复设 TTL 导致锁延长。
示例逻辑(使用 github.com/go-redis/redis/v8):
key := fmt.Sprintf("login:fail:%s", username)
cnt, err := rdb.Incr(ctx, key).Result()
if err != nil {
// 处理 Redis 错误,但不要阻断登录流程
return
}
if cnt == 1 {
rdb.Expire(ctx, key, 15*time.Minute) // 首次失败才设过期
}
if cnt >= 5 {
c.JSON(403, gin.H{"error": "account locked for 15 minutes"})
return
}
- 注意
INCR返回的是递增后的值,不是原始值 -
EXPIRE必须只在第一次失败时调用,否则每次失败都会刷新 TTL,锁永远不生效 - 不要用
SET key val EX 900替代 —— 它无法原子递增,竞态下会漏计数
Gin 中间件里怎么安全读写锁定状态
中间件要覆盖登录接口(如 POST /login),但不能干扰其他路由;同时得区分“已锁定”和“未达阈值”,还要支持手动解锁(比如管理员后台)。
- 检查锁定状态放在认证逻辑之前,用
rdb.Exists(ctx, key).Val() > 0判断是否已被锁 - 成功登录后,务必调用
rdb.Del(ctx, key)清除计数器,否则下次输错仍受限 - 如果用本地内存(如
sync.Map),必须自己启动 goroutine 定期扫描过期项,不如 Redis 省心 - 错误响应不要暴露“还剩几次机会”,防止攻击者探测阈值
为什么不用 JWT 自带的黑名单做账号锁定
JWT 黑名单(如 Redis 存 token jti)管的是“已登录但需强制登出”的场景,而暴力破解防御针对的是“尚未通过认证的请求”。两者生命周期和触发时机完全不同。
- 账号锁定发生在
username/password校验阶段,此时还没生成 token - 黑名单只对已签发 token 有效,对撞库请求无感知
- 混用会导致逻辑割裂:锁账号了但旧 token 还能用,或者反过来锁 token 却不限制新登录
真正要防的是密码猜解行为本身,不是 token 泄露。锁定粒度必须落在用户名维度,且带服务端强状态控制——这恰恰是 Redis 计数器最擅长的。











