ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

短信验证码登录从原理到实战:Redis存储与防刷设计全解析

短信验证码登录从原理到实战:Redis存储与防刷设计全解析 1. 短信验证码登录的整体设计与思路拆解做登录功能最头疼的是“手机号 密码”这套老方案在真实业务里永远绕不开两个问题密码泄露风险高用户记不住就流失。尤其是在移动端场景用户打开 App 连键盘都不想弹更别提背一串密码了。短信验证码登录之所以能成为主流方案本质上是把“身份凭证”从“用户记忆的信息”变成了“用户持有的手机号 时间窗口内的动态码”一来砍掉了注册/找回密码的流程漏斗二来把风控重心转移到运营商链路和验证码使用策略上整体安全性反而更容易掌控。从技术架构上看短信验证码登录并不复杂但它横跨了前端交互、后端接口设计、缓存存储、第三方短信服务商对接、风控策略多个层级。任何一个环节做糙了都会直接体现在用户体验和安全事故上。我见过不少团队第一版登录接口上线第一天就被刷爆短信额度也见过验证码 30 分钟有效导致撞库风险敞开的案例所以这篇文章我会把完整实现链路拆开讲从验证码生成、存储、发送、校验到登录态创建、防刷策略、异常排查全部过一遍。这套方案适合谁参考如果你是正在做 Web 或移动端登录模块的后端工程师或者小团队要快速上线一套可靠安全的注册登录体系又或者你只是想把“短信验证码登录”的原理彻底搞清楚这篇文章都能给你一个可以直接落地的最小闭环附带我踩过坑之后总结的生产级配置建议。1.1 为什么用短信验证码而不是密码登录先想清楚一个核心问题验证码登录到底在验证什么它验证的是“这个手机号当前可以被你接收短信”。注意它并不验证“你是这个手机号的主人”这是短信登录天生无法回避的边界。所以短信验证码登录适合大多数 C 端业务但如果你做的是银行、政务这类强实名场景短信验证码只能作为多因子认证中的一环不能单独作为高敏操作凭证。那为什么替代密码从产品和工程两个角度都有硬理由。产品层面用户不需要记密码登录转化率高尤其适合低频工具类应用用户三个月回来一次密码早就忘了短信验证码一条搞定。工程层面不需要维护密码哈希、盐值、密码策略、找回流程、异地登录检测这些复杂逻辑运维成本直降。但代价也很明确短信通道有成本、有延迟、有到达率波动而且验证码一旦被恶意刷取账单会非常难看。所以短信登录从来不是一个“发出去就完事”的功能它背后必须配套频控、验证码策略、日志监控三件套。1.2 验证码登录的整体技术链路我把完整链路画成一条主线心里有这条主线后面每个环节你都知道自己在做什么用户输入手机号前端先做基础格式校验然后调发送验证码接口后端校验频控规则生成 6 位随机验证码存 Redis 并设置过期时间后端调用短信服务商 API把验证码推送到用户手机用户输入验证码前端把手机号 验证码 设备指纹可选提交给登录接口后端从 Redis 取出验证码比对是否正确、是否过期、是否超过尝试次数校验通过后删除验证码一次性使用创建登录态Token / Session返回用户信息和凭证后续请求通过凭证访问业务接口这条链路每一步都有不少细节比如验证码有效期设置多长、Redis key 怎么设计、短信模板怎么处理签名、登录态用什么方案、失败重试上限多少次下面一个个拆。2. 核心细节解析与实操要点2.1 验证码的生成策略与安全规则验证码首先要解决“怎么生成才安全”。很多人随手写一个random.randint(100000, 999999)这在低并发场景下问题不大但严格来说random模块是伪随机数生成器如果被攻击者拿到足够多的样本是有可能预测后续序列的。生产环境建议使用secrets模块Python或SecureRandomJava这类密码学安全的随机数源生成的验证码不可预测性才有保障。验证码位数建议 6 位数字。4 位虽然好记但只有一万种组合配合接口频控不当暴力枚举成本很低。6 位是百万级组合配合“单手机号每日上限”和“验证码错误 5 次作废”暴力破解的时间成本已经被拉得很高。有效期多长我的实践值是 5 分钟。太短用户还没看清短信就过期体验极差太长又给暴力破解留了窗口。5 分钟是用户感知和安全性之间比较平衡的点。还有一个细节验证码短信文案里必须注明“验证码仅用于登录请勿泄露”并且加工程后缀防止用户把验证码随手填进钓鱼页面。2.2 验证码存储Redis 是标配但不是存一个 key 就行验证码需要“临时存储 过期 单次有效”这几乎是 Redis 的教科书使用场景。存储结构不能只存验证码本身我推荐用 Hash 结构一个 key 对应多个字段key: sms:login:13800138000 field: code 483920 field: count 0 field: send_time 1735000000code存验证码count记录该验证码已被尝试校验的次数超过阈值自动作废send_time记录发送时间用于频控和日志追溯key 必须带业务前缀和手机号方便统一管理过期和排查问题。全部字段设置 5 分钟过期用EXPIRE命令。注意一个容易犯的错用SET code 483920 EX 300这种字符串结构也能存但你没法原子性维护尝试次数得额外再建一个 key两个 key 之间的一致性和过期管理就变复杂了。Hash 一步到位。校验的时候用 Lua 脚本保证原子性避免并发请求同时读到同一个验证码都能通过校验。Lua 脚本大致逻辑-- KEYS[1] sms:login:手机号 -- ARGV[1] 用户输入的验证码 local code redis.call(HGET, KEYS[1], code) if not code then return 0 end -- 不存在或已过期 local count tonumber(redis.call(HGET, KEYS[1], count) or 0) if count 5 then redis.call(DEL, KEYS[1]) return -2 -- 错误次数过多验证码作废 end if code ~ ARGV[1] then redis.call(HINCRBY, KEYS[1], count, 1) return -1 -- 验证码错误 end redis.call(DEL, KEYS[1]) -- 校验成功立即删除一次性 return 1这样设计的好处是校验和删除在同一个原子操作里完成不会出现 A 请求校验通过后、B 请求又用同一个验证码校验通过的竞态问题。2.3 短信服务商选型与对接国内主流短信服务商有阿里云短信、腾讯云短信、华为云短信也有聚合类服务商。选型主要看三样东西到达率、价格、审核速度。到达率直接决定用户体验建议选有多条通道冗余的服务商或同时接两家做故障切换价格一般按条计费区间在 0.03 到 0.05 元之间量大了可以谈审核速度短信签名和模板都要过审新注册账号审核周期 1 到 3 天别等到上线前一天才去申请对接流程大同小异先在控制台申请签名比如“XX科技”再申请模板比如“您的登录验证码是 ${code}5 分钟内有效请勿泄露”拿到 AccessKey ID 和 AccessKey Secret然后调用服务商提供的 SDK 发送短信。代码层面要注意一个细节服务商 API 返回成功不代表用户一定收到了短信中间还有运营商通道、用户手机拦截、信号问题所以发送接口要做异步状态回执监听把发送失败或用户实际未收到的状态回写到业务日志里后续排查“用户说没收到短信”的时候有据可查。2.4 前端交互设计前端这块容易被后端忽视但它直接决定接口的防刷压力。一个设计良好的前端交互能把恶意请求挡在第一道门外减少后端被直接刷的暴露面。手机号输入框做实时格式校验不符合规则时“获取验证码”按钮置灰点击“获取验证码”后按钮进入 60 秒倒计时这个倒计时必须在后端也做限制前端倒计时只是优化体验防不了绕过前端的直接请求强烈建议在发送验证码前加入图形验证码或滑块验证。这一步很多人觉得繁琐不愿加但它能有效拦截脚本批量刷短信节省的成本远超那点体验损耗登录按钮在验证码输入完成后才可点击减少无效请求前端友好度的另一个细节如果用户手机号输错了短信发给了别人的手机号用户还一直等验证码这是最常见的客诉之一。所以发送前一定要让用户确认手机号倒计时期间把手机号显示在按钮上“已发送至 138****8000”。3. 实操过程与核心环节实现3.1 环境准备与依赖安装我这里用一个 Spring Boot 项目做示例因为国内 Java 后端的普及率最高但整套思路迁移到 Go、Python 是零成本的。先列依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.aliyun/groupId artifactIddysmsapi20170525/artifactId version2.0.24/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependencyRedis 用 Spring Data Redis短信用阿里云短信 SDK其他就是 Web 基础依赖。Redis 的配置没啥好说的关键是确保StringRedisTemplate可用后面存取验证码都用它。3.2 发送验证码接口实现先做基础校验再做频控最后生成验证码并发送。顺序不能乱频控判断一定要在调短信 API 之前否则恶意用户直接把你的短信额度刷爆了。PostMapping(/api/sms/send) public Result sendSmsCode(RequestBody SendSmsRequest request) { // 1. 校验手机号格式 if (!RegexUtil.isMobile(request.getPhone())) { return Result.error(手机号格式不正确); } // 2. 频控校验同一手机号 60 秒内只能发送一次 String freqKey sms:freq: request.getPhone(); Boolean acquired stringRedisTemplate.opsForValue() .setIfAbsent(freqKey, 1, Duration.ofSeconds(60)); if (Boolean.FALSE.equals(acquired)) { return Result.error(发送过于频繁请稍后再试); } // 3. 频控校验同一 IP 每天发送次数上限比如 10 次 String ipKey sms:ip: getClientIp(request); Long ipCount stringRedisTemplate.opsForValue().increment(ipKey); if (ipCount ! null ipCount 10) { return Result.error(今日发送次数已达上限); } stringRedisTemplate.expire(ipKey, Duration.ofDays(1)); // 4. 生成 6 位安全随机验证码 String code String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); // 生产环境用 SecureRandom // SecureRandom random new SecureRandom(); // String code String.format(%06d, random.nextInt(1000000)); // 5. 存储到 Redis5 分钟过期 String redisKey sms:login: request.getPhone(); MapString, String map new HashMap(); map.put(code, code); map.put(count, 0); map.put(send_time, String.valueOf(System.currentTimeMillis())); stringRedisTemplate.opsForHash().putAll(redisKey, map); stringRedisTemplate.expire(redisKey, Duration.ofMinutes(5)); // 6. 调用短信服务商 API smsService.sendLoginCode(request.getPhone(), code); return Result.success(验证码已发送); }这里有几个生产级细节要强调setIfAbsent本身就是原子操作天然避免两个人同时请求通过频控IP 维度的 TTL 设置要在increment之后否则第一次自增后 key 没有过期时间永远不过期频控规则应该做成可配置项存配置文件或配置中心不要在代码里写死方便运营调整3.3 登录校验验证码接口实现登录接口的核心是验证码校验 登录态创建。校验逻辑我已经在前面用 Lua 脚本写过了这里重点看登录接口如何组织PostMapping(/api/login/sms) public Result loginBySms(RequestBody LoginBySmsRequest request) { // 1. 参数校验 if (!RegexUtil.isMobile(request.getPhone())) { return Result.error(手机号格式不正确); } if (!RegexUtil.isCode(request.getCode())) { return Result.error(验证码格式不正确); } // 2. 执行 Lua 脚本校验验证码 String redisKey sms:login: request.getPhone(); Long result stringRedisTemplate.execute( CHECK_CODE_SCRIPT, Collections.singletonList(redisKey), request.getCode() ); if (result 0) { return Result.error(验证码已过期请重新获取); } if (result -1) { return Result.error(验证码错误请重新输入); } if (result -2) { return Result.error(错误次数过多请重新获取验证码); } // 3. 查询或创建用户 User user userService.findOrCreateByPhone(request.getPhone()); // 4. 创建登录态签发 token String token jwtService.createToken(user.getId(), user.getPhone()); // 5. 记录登录日志审计用 loginLogService.record(user.getId(), getClientIp(request), request.getDeviceId()); return Result.success(new LoginResponse(token, user)); }3.4 登录态方案选型JWT 还是 Session登录态这块需要单独说明一下因为它的选型直接影响后续扩展性。两种方案各有适用场景对比维度JWTSession Redis无状态是服务端不存状态否需要 Redis 存储分布式扩展天然支持任意节点可验证需要统一 Redis 存储会话主动踢人不方便需要黑名单机制方便删 Session 即可客户端存储客户端持有 token客户端只持有 sessionId安全性依赖签名算法需控制 token 有效期依赖 sessionId 随机性和存储安全我的建议是单体应用或小团队用 Session Redis 更省心管理踢人、封号、会话列表都很方便微服务或前后端完全分离用 JWT 更合适但要配合短期 token 刷新 token 的双 token 方案避免 token 泄露后长期有效。无论选哪种登录成功后的返回结构都应该包含凭证、用户基本信息、必要的初始化配置比如是否是新用户前端决定是否弹新手引导。新用户判断很重要因为验证码登录通常伴随隐式注册首次登录的新用户要标记出来方便后续引导完善头像昵称。3.5 短信服务商的异步回执处理这条属于“不做会踩坑做了没感觉”的环节。短信服务商都会提供发送状态回执回调短信实际送达、被运营商拦截、手机号停机、用户退订等状态会异步推送到你配置的回调地址。务必实现这个回调并落库PostMapping(/api/sms/callback) public Result smsCallback(RequestBody SmsCallbackRequest callback) { // 记录手机号、发送时间、状态、描述 smsReportService.save(new SmsReport( callback.getPhone(), callback.getBizId(), callback.getStatus(), callback.getDescription() )); return Result.success(); }有了这个回执数据你再遇到“用户说没收到短信”的客诉可以快速查链路服务商 API 是否返回成功、回执状态是送达还是拦截、用户手机是否停机。很多团队不做这一步排查问题全靠猜不仅效率低还容易被用户认为是平台问题。4. 常见问题与排查技巧实录4.1 用户收不到验证码问题定位清单收不到验证码是最高频的客诉没有之一。我自己排查过几十次原因无非这几种按出现概率排序可能原因特征解决方案短信被手机系统或安全软件拦截同一个手机号换其他手机测试能收到引导用户检查拦截短信、关闭骚扰拦截用户手机号停机或欠费服务商回执显示发送失败原因停机提示用户联系运营商用户主动退订了营销短信回执显示退订登录类短信通常不该走营销通道换通知类短信通道通道拥堵或服务商故障发送回执延迟大或丢失服务商控制台查通道状态必要时切换备用通道验证码发送接口被频控拦截用户短时间内多次点击导致触发频控客服后台解封或引导用户稍后再试手机号格式校验太严格用户手机号是 17 开头被误判更新正则兼容最新号段排查时先查服务商回执状态再问用户的手机厂商和所在地最后再排查代码逻辑这个顺序能最快定位问题。4.2 验证码安全问题被刷、被撞、被劫持验证码功能上线后黑产盯上它是必然的不是如果而是时间问题。我总结这些年遇到的攻击方式和对应防御措施短信轰炸恶意脚本批量提交手机号刷爆短信额度。防御方案是图形验证码/滑块验证前置、单手机号发送频控、单 IP 频控、设备指纹识别。再加一道针对异常高频的 IP 段做临时黑名单阈值到就封一段时间。验证码撞库攻击者拿泄露的验证码批量尝试登录。防御方案是错误次数限制5 次作废、单 IP 失败次数限制、登录接口加验证码、验证码一次性有效。生产环境还必须记录失败日志并设置告警连续失败达到阈值自动锁 IP。接口重放攻击攻击者截获请求后重复发送同样的验证码。防御方案是验证码一次性使用校验通过立即删除、请求加时间戳和签名、HTTPS 全链路加密。有时候用户网络差会重试一次这里要设计好幂等策略不能因为重放导致用户被误判为攻击。很多团队一开始只想“快速上线”把图形验证码省了结果一天晚上收到一条短信费用告警一夜刷了几百块钱第二天乖乖把滑块验证加上了。这笔账不用算也知道哪个划算。4.3 并发场景下的验证码竞态问题并发下典型的问题是用户同时从两个设备发起验证码校验代码里如果先查询验证码、比对、再删除两步之间就有竞态窗口。两个请求可能同时读到同一个正确验证码都通过校验都登录成功。这在业务上就是“一个验证码用了两次”。解决方式我在前面已经用 Lua 脚本实现了核心宗旨是“校验和删除必须在同一个原子操作里完成”。这是我在生产环境真实遇到过的坑最初版本用的是先查再比再删上线后运营反馈某个用户反复在两个设备间切换偶尔会发
返回列表