不能在http中间件里校验短信验证码,因为其本质是状态性、时效性、一次性操作,必须与具体业务动作(如换绑手机号)强绑定;中间件缺乏上下文(如user_id、action_id),易导致重放或越权复用,正确做法是在路由handler中用redis getdel原子操作校验并即时删除key。

为什么不能直接在 HTTP 中间件里校验短信验证码
因为短信验证码本质是状态性操作,和普通鉴权不同:它必须绑定具体业务动作(比如修改手机号)、有严格时效(通常 5 分钟)、且用完即失效。中间件无法知道当前请求要执行哪个敏感操作,更没法动态关联 operation_id 或 phone_number 等上下文参数。
常见错误是写一个通用中间件,对所有带 X-Verify-Token 的请求统一查 Redis 验证——结果导致 A 用户的验证码被 B 用户拿来重放,或同一验证码被重复用于改密、换绑、支付多个动作。
- 验证码必须和业务动作强绑定,建议用 Redis key 设计为
verify:change_phone:{user_id}:{action_timestamp},而非简单verify:{phone} - 验证通过后必须立刻
DEL对应 key,防止重放;若业务失败(如数据库更新失败),需考虑是否回滚验证码状态(一般不回滚,由前端重新触发获取) - 不要依赖客户端传来的
timestamp做防重放,应以服务端生成的action_id或签名哈希为准
如何在 Gin 路由 handler 中安全接入验证码校验
Gin 没有内置“二次校验”机制,得在具体 handler 开头手动做:解析参数 → 构建唯一校验 key → 查 Redis → 校验内容与时效 → 删除 key → 继续业务逻辑。关键点在于校验和业务必须在同一事务边界内完成(至少保证原子性)。
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
示例场景:用户提交「更换绑定手机号」请求,携带 new_phone、verify_code、action_id:
// handler 示例
func ChangePhone(c *gin.Context) {
var req struct {
NewPhone string `json:"new_phone"`
VerifyCode string `json:"verify_code"`
ActionID string `json:"action_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
// 1. 构建唯一校验 key(含 user_id 和 action_id)
uid := c.MustGet("user_id").(int64)
key := fmt.Sprintf("verify:change_phone:%d:%s", uid, req.ActionID)
// 2. 从 Redis 获取并删除(GETDEL 原子操作)
code, err := rdb.GetDel(ctx, key).Result()
if err == redis.Nil {
c.JSON(400, gin.H{"error": "invalid or expired verify code"})
return
} else if err != nil {
c.JSON(500, gin.H{"error": "redis error"})
return
}
// 3. 比对验证码(注意恒定时间比较,防时序攻击)
if !bytes.Equal([]byte(code), []byte(req.VerifyCode)) {
c.JSON(400, gin.H{"error": "code mismatch"})
return
}
// ✅ 此时才执行真正敏感操作:更新手机号、清理旧会话等
if err := updateUserPhone(uid, req.NewPhone); err != nil {
c.JSON(500, gin.H{"error": "update failed"})
return
}
c.JSON(200, gin.H{"ok": true})
}
为什么用 GETDEL 而不是 GET + DEL
并发场景下,两个相同请求可能同时通过 GET 拿到验证码,再各自 DEL,导致第二个请求误判为“已使用”,实际却完成了两次业务操作。Redis 的 GETDEL 是原子命令,拿值的同时删 key,彻底杜绝竞态。
- Go Redis 客户端(如 github.com/go-redis/redis/v9)中调用为
rdb.GetDel(ctx, key) - 若 Redis 版本 EVAL "local v = redis.call('get', KEYS[1]); redis.call('del', KEYS[1]); return v" 1 key
- 切勿用
SETNX+ 过期时间模拟,这会引入额外延迟和复杂性,且无法保证取值与删 key 原子性
前端传 action_id 还是后端生成更安全
必须由后端生成并返回给前端。前端自行生成 UUID 或时间戳,容易被构造伪造,失去绑定意义。正确流程是:前端点击「换绑手机号」→ 后端生成 action_id(如 sha256(fmt.Sprintf("%d-%d", user_id, time.Now().UnixNano()))[:16])→ 写入 Redis(带过期)→ 返回给前端 → 前端提交时带上该 action_id。
-
action_id不需要保密,但必须不可预测、不可重放;避免用纯时间戳或自增 ID - Redis key 的过期时间应略长于短信发送间隔(如短信 60 秒发出,key 设 300 秒),确保网络延迟不影响验证
- 如果用户多次触发「获取验证码」,旧的
action_id对应 key 应被新请求覆盖(用SET key val EX 300 NX),避免堆积
action_id 怎么生成、key 怎么拼、GETDEL 怎么用错、并发怎么漏——每个点都卡在真实上线时的报错日志里。别省那几行代码,也别信“先跑通再加固”的想法。golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










