ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手机验证码登录全链路:Spring Boot+Redis限流防刷与JWT实战

手机验证码登录全链路:Spring Boot+Redis限流防刷与JWT实战 一个做后端的朋友上个月找我喝茶说他接了个新项目登录模块产品拍板要做手机验证码登录他第一反应是这玩意儿不就发条短信嘛结果真动手才发现坑不少验证码存哪、多久过期、怎么防刷、前端倒计时跟后端限流对不上、同一个手机号连点五次短信欠费两百块。手机验证码登录听上去简单但它是一条横跨前端交互、后端逻辑、短信通道、缓存中间件的完整链路任何一环设计得糙一点上线后都会被真实用户按在地上摩擦。这篇就把整套代码实现从头到尾拆一遍后端用 Spring Boot Redis 举例前端用 Vue3 Element Plus 举例其他技术栈的思路完全通用。不管你是刚学前后端分离想找个完整案例练手的新人还是接手了登录模块想重构的老手下面的内容都能直接抄。1. 手机验证码登录到底解决了什么问题1.1 从账号密码到验证码用户省掉的其实是记忆成本先说清楚为什么要做这件事不然代码写完了也不知道自己写的价值在哪。账号密码体系最大的问题是它把安全责任推给了用户用户要记住密码记不住就设简单的设简单了就被撞库最后平台背锅。手机验证码把这套逻辑反过来了验证要素由平台生成、由平台通过一个用户独享的通道短信下发用户只需要证明这个手机号在我手上不需要记忆任何东西。对用户来说注册和登录合并成一步第一次进入就是登录转化率提升非常明显。对平台来说手机号天然是实名体系的一部分后续做风控、做找回、做通知都有抓手。但要注意验证码登录不是没有代价的。它引入了短信成本、通道依赖和额外的时延。所以真正落地的产品通常是混合的新用户走验证码自动注册老用户既支持验证码也支持密码还有一些高价值场景会再叠一层图形验证码或者设备指纹。我个人的经验是第一版别想太复杂先把发码—收码—校验—签发登录态这条主链路跑通跑稳再考虑叠加风控。1.2 主流登录方式横向对比为什么它值得单独做一套很多人会问既然有微信扫码登录、有账号密码为什么还要单独做一套因为它们的适用场景不一样。我列个表你在做技术选型的时候可以对着看登录方式用户操作成本开发成本短信/第三方成本适用场景账号密码高需记忆低无内部系统、老用户存量手机验证码低收短信即可中每条短信有成本面向 C 端的通用登录第三方扫码极低中高需对接免费但有依赖有社交属性的产品邮箱链接中要切应用中极低海外产品、开发者工具从表里能看出来手机验证码登录处在一个很甜的位置用户成本低、开发成本可控、不依赖某一家外部生态。代价就是短信费用和通道稳定性所以后端的限流和缓存设计必须认真做这两块是省钱和防刷的关键。1.3 一次完整登录前端和后端各自负责哪一段把链路摊开来看一次成功的验证码登录大概经过这么几步用户输入手机号前端做格式校验前端调发送接口后端先查频率限制通过后生成验证码写进 Redis再调短信服务商下发用户收到短信填进输入框前端调登录接口后端从 Redis 取出验证码比对一致就删除然后查用户表没有就自动注册有就取出最后签发登录凭证一般是 JWT返回前端拿到凭证存起来跳转首页。这里有个职责划分的原则我想强调一下所有跟安全相关的判断必须放在后端。前端的倒计时、手机号正则、按钮禁用都只是体验优化用户随手改个 JS 就能绕过所以后端必须独立再做一遍手机号格式校验、频率限制、验证码校验。我在评审代码的时候见过前端加了个60 秒内不能点就以为万事大吉的那个接口被脚本一刷短信费一晚上跑掉好几千。2. 后端核心验证码生成、存储与校验的完整实现2.1 验证码怎么生成才够随机生成环节看似最简单但随机源选错了就是灾难。千万别用Math.random()它的种子可预测在安全场景下是不合格的。用SecureRandom位数上我建议 6 位纯数字原因有两个一是短信里用户看得清、输得快二是 6 位数字有 100 万种组合配合 5 分钟有效期和次数限制暴力枚举的窗口非常小。private static final SecureRandom RANDOM new SecureRandom(); public String generateCode() { int value RANDOM.nextInt(1_000_000); // 补齐前导零保证永远是 6 位 return String.format(%06d, value); }提示不要用递增序列、时间戳后六位、手机号后六位这类伪随机来做验证码这类码在数据泄露时几乎等于明文。2.2 Redis 的键设计决定你后面排查问题顺不顺手存储我用 Redis不用数据库也不用 Session理由是它天然带 TTL、读写快、能做原子操作。键名一定要规范我习惯用冒号分段前缀带上业务这样在客户端里keys sms:login:*一眼就能看出哪些是登录验证码private static final String CODE_KEY sms:login:code:%s; // 验证码 private static final String LIMIT_KEY sms:login:limit:%s; // 手机号维度限流 private static final String IP_LIMIT_KEY sms:login:iplimit:%s; // IP 维度限流 public void saveCode(String phone, String code) { String key String.format(CODE_KEY, phone); redisTemplate.opsForValue().set(key, code, Duration.ofMinutes(5)); }有效期我设 5 分钟这是行业里比较通用的值。太短用户来不及收太长被撞库的风险窗口变大。这里有个细节值得说验证码过期后 Redis 会自动删掉键你不需要写定时清理任务这也是选 Redis 而不是数据库的核心原因之一。如果非要用数据库你得加一个字段存过期时间再跑一个定时任务扫成本和复杂度都上去了。另外键名里的手机号建议做脱敏处理或者哈希我一般直接用手机号因为 Redis 本身在内网但如果你的 Redis 有多个业务共用用哈希会更稳妥一些。2.3 限流要分三个维度做只做一层等于没做限流是这块最容易被忽略、也最容易出事故的地方。只对手机号限流不够因为脚本可以换手机号只对 IP 限流也不够因为手机号会被反复刷。我一般做三层第一层是手机号维度同一个手机号 10 分钟内最多发 3 条防止用户自己狂点或者被定向骚扰。第二层是IP 维度同一个 IP 1 小时内最多发 20 条挡住脚本批量刷。第三层是全局维度整个系统每分钟发送量设一个上限超出就熔断这是防止短信通道被刷爆的最后一道闸。public void checkLimit(String phone, String ip) { // 手机号维度10 分钟内最多 3 次 String phoneKey String.format(LIMIT_KEY, phone); Long phoneCount redisTemplate.opsForValue().increment(phoneKey); if (phoneCount ! null phoneCount 1) { redisTemplate.expire(phoneKey, Duration.ofMinutes(10)); } if (phoneCount ! null phoneCount 3) { throw new BizException(发送太频繁了请稍后再试); } // IP 维度1 小时内最多 20 次 String ipKey String.format(IP_LIMIT_KEY, ip); Long ipCount redisTemplate.opsForValue().increment(ipKey); if (ipCount ! null ipCount 1) { redisTemplate.expire(ipKey, Duration.ofHours(1)); } if (ipCount ! null ipCount 20) { throw new BizException(当前网络环境请求过于频繁); } }这里有个坑必须提醒increment和expire是两次操作如果服务在中间挂了键就会永不过期越积越多。生产环境我建议用 Lua 脚本把这两步合成原子操作或者直接用 Spring Data Redis 的RedisAtomicLong配合过期设置。这个坑我是真踩过运维某天报警说 Redis 内存涨得离谱排查半天发现是一堆没有 TTL 的限流键。2.4 校验必须是一次性的用 Lua 保证原子性校验逻辑有个经典并发问题用户在 60 秒内点两次登录两个请求同时到达都读到了验证码都判断成功结果同一个码用了两次。要杜绝这种情况校验和删除必须是原子的。最稳的做法是写一段 Lua 脚本-- KEYS[1]: 验证码的 key -- ARGV[1]: 用户提交的验证码 -- 返回: 1 成功 0 验证码错误 -1 已过期或不存在 local stored redis.call(GET, KEYS[1]) if stored false then return -1 end if stored ~ ARGV[1] then return 0 end redis.call(DEL, KEYS[1]) return 1Java 侧调用private static final DefaultRedisScriptLong VERIFY_SCRIPT new DefaultRedisScript( local stored redis.call(GET, KEYS[1]) if stored false then return -1 end if stored ~ ARGV[1] then return 0 end redis.call(DEL, KEYS[1]) return 1, Long.class ); public void verifyCode(String phone, String inputCode) { String key String.format(CODE_KEY, phone); Long result redisTemplate.execute(VERIFY_SCRIPT, Collections.singletonList(key), inputCode); if (result null || result -1) { throw new BizException(验证码已过期请重新获取); } if (result 0) { throw new BizException(验证码不正确); } }注意校验失败时不要删除验证码否则用户输错一次就得重新发体验很差。只有校验成功才删。还有一点验证码输错的次数也要限制我一般允许错 5 次超过就锁定这个手机号 15 分钟。不然 6 位数字在 5 分钟窗口内理论上是可以被撞出来的虽然概率极低但加上错误次数限制就是双保险。3. 短信通道对接与接口契约设计3.1 接口怎么划分两个接口就够整个模块对外只需要两个接口分得太细反而增加前端负担POST /api/sms/login/code发送验证码入参手机号出参为空。POST /api/sms/login验证码登录入参手机号 验证码出参是 token 和用户基本信息。用 POST 而不是 GET 是因为这两个操作都会改变服务端状态写 Redis、写用户表不符合 GET 的幂等语义而且手机号放在 URL 里会被日志记录不太合适。RestController RequestMapping(/api/sms/login) public class SmsLoginController { PostMapping(/code) public ResultVoid sendCode(RequestBody Valid SmsCodeRequest req) { smsLoginService.sendCode(req.getPhone()); return Result.ok(); } PostMapping public ResultLoginVO login(RequestBody Valid SmsLoginRequest req) { return Result.ok(smsLoginService.login(req.getPhone(), req.getCode())); } }入参用Valid加注解校验别在业务代码里写一堆 if 判断手机号格式那样代码会很脏public class SmsCodeRequest { NotBlank(message 手机号不能为空) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; // getter / setter }3.2 对接短信服务商的几个实操要点国内主流云厂商都提供短信服务接入流程大同小异申请签名和模板、拿到 AccessKey、调 SDK 发。这里有几个经验点值得说。第一短信模板必须提前报备审核这不是代码问题而是流程问题很多团队代码写完了卡在模板审核上所以项目一开始就要把模板提上去。第二发送结果要异步处理不要同步等短信平台响应因为一旦对方接口抖动你的登录接口就被拖死了。我的做法是把发送动作丢进线程池或者消息队列接口立刻返回已发送真实结果通过回调或者日志记录。第三Key 千万别写死在代码里用配置中心或者环境变量这是最基本的安全要求。Async(smsExecutor) public void sendSmsAsync(String phone, String code) { try { // 伪代码调用短信服务商 SDK SmsClient.send(phone, templateId, Map.of(code, code)); log.info(验证码短信已发送, phone{}, maskPhone(phone)); } catch (Exception e) { // 发送失败要把 Redis 里的验证码删掉避免用户收到空等 redisTemplate.delete(String.format(CODE_KEY, phone)); log.error(验证码短信发送失败, phone{}, maskPhone(phone), e); } }提示日志里打印手机号一定要脱敏138****8888这种格式日志文件泄露导致用户信息外流是很低级的失误。3.3 前后端返回结构必须提前约定死前后端分离项目最容易扯皮的就是返回结构。我的建议是第一版就把结构定下来写进接口文档谁都别改。我用的统一结构是这样的字段类型说明codeint0 表示成功非 0 是业务错误码messagestring给用户看的提示语dataobject业务数据失败时为 null错误码不要只有 0 和 1业务错误要能区分开前端才能做针对性提示。比如 1001 是手机号格式错误1002 是发送过于频繁1003 是验证码错误1004 是验证码过期。前端拿到 1003 就提示验证码错误拿到 1004 就提示验证码已过期请重新获取体验会好很多。4. 前端实现从输入框到倒计时的状态机4.1 表单结构和基础校验前端这块用 Vue3 Element Plus 举例结构很简单一个手机号输入框、一个验证码输入框带发送按钮、一个登录按钮。手机号输入框限制 11 位数字验证码限制 6 位数字用maxlength和inputmodenumeric让它调起数字键盘移动端体验会好很多。template el-form :modelform label-width0 el-form-item el-input v-modelform.phone maxlength11 inputmodenumeric placeholder请输入手机号 inputform.phone form.phone.replace(/\D/g, ) / /el-form-item el-form-item div classcode-row el-input v-modelform.code maxlength6 inputmodenumeric placeholder请输入验证码 inputform.code form.code.replace(/\D/g, ) / el-button :disabledcountdown 0 || sending clickhandleSend {{ countdown 0 ? ${countdown}s 后重发 : 获取验证码 }} /el-button /div /el-form-item el-button typeprimary :loadinglogging clickhandleLogin登录/el-button /el-form /templateinput里做实时过滤比提交时才校验体验好用户粘贴带空格的手机号也能自动清理掉。这个小细节很多人不做结果用户从通讯录复制过来带个空格就报错很容易被投诉。4.2 倒计时按钮的状态管理倒计时的核心是按钮状态由倒计时变量驱动不要手动去切换 disabled那样状态多了必然乱。用一个countdown的 ref 就够了大于 0 就是禁用状态。const countdown ref(0) let timer null const startCountdown () { countdown.value 60 timer setInterval(() { countdown.value - 1 if (countdown.value 0) { clearInterval(timer) timer null } }, 1000) } onUnmounted(() { if (timer) clearInterval(timer) })这里有个特别容易忘的点组件卸载时必须清掉定时器。SPA 项目里用户发送验证码后立刻跳到别的路由定时器还在跑会一直占用内存反复进出页面就可能出现多个定时器叠加、倒计时跳变的问题。我在项目里见过倒计时从 60 直接跳到 57 的 bug就是这个原因。另外倒计时结束时间建议用时间戳算而不是每次减一因为setInterval在页面切到后台时会被浏览器节流用 60 减一的方式回到前台会不准。改成记录一个结束时间戳每次 tick 用结束时间 - 当前时间计算剩余秒数就完全准了。4.3 请求封装和错误提示请求统一走 axios 实例把 baseURL、超时、拦截器都配好业务代码里只关心数据。响应拦截器里统一处理错误码前端的提示语就能保持一致性。const request axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000, }) request.interceptors.response.use( (res) { const { code, message, data } res.data if (code 0) return data ElMessage.error(message || 请求失败) return Promise.reject(new Error(message)) }, (err) { ElMessage.error(网络异常请稍后重试) return Promise.reject(err) } )发送验证码和登录的处理函数要注意一点按钮要有 loading 状态。用户在弱网环境下点了没反应就会狂点如果后端限流没做好点几下就欠费了就算限流做好了用户也会收到请求过于频繁的报错体验很差。const handleSend async () { if (!/^1[3-9]\d{9}$/.test(form.phone)) { ElMessage.warning(请输入正确的手机号) return } sending.value true try { await sendSmsCode({ phone: form.phone }) ElMessage.success(验证码已发送) startCountdown() } catch (e) { // 拦截器已经提示过了 } finally { sending.value false } }前端的手机号正则和后端保持一致这样能在本地就拦掉大部分无效请求减少对后端的无谓冲击。记住前面说的这个校验只是体验优化不能替代后端校验。5. 登录成功后JWT 签发与前端凭证存储5.1 为什么选 JWT 而不是 Session前后端分离项目里Session 模式的问题在于服务端要存会话多实例部署还得做共享运维成本上去了。JWT 是自包含的服务端只负责签发和验签不存状态特别适合无状态的服务集群这也是它在 SPA 项目里普及的原因。我的实现里登录校验通过后查一次用户表不存在就自动注册然后签发一个有效期为 7 天的 token。public LoginVO login(String phone, String code) { verifyCode(phone, code); User user userMapper.selectByPhone(phone); if (user null) { user new User(); user.setPhone(phone); user.setNickname(用户 phone.substring(7)); userMapper.insert(user); } String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(phone, phone) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(jwtKey) .compact(); return new LoginVO(token, user.getId(), user.getNickname()); }自动注册这里有几个细节昵称默认值不要用手机号全量那样会在前端显示里泄露手机号并发注册同一个手机号可能产生两条数据所以数据库要对手机号字段建唯一索引靠数据库来兜底别指望应用层查一次就万事大吉。5.2 前端 token 存哪以及怎么自动带上token 存储有两种主流做法localStorage 和 HttpOnly Cookie。localStorage 简单但存在被脚本读取的风险HttpOnly Cookie 更安全但需要处理跨域凭证配置麻烦一点。我的建议是普通项目用 localStorage 加短期 token比如 2 小时加刷新机制就够了安全要求高的项目用 HttpOnly Cookie。用 localStorage 的话在请求拦截器里统一带上request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })退出登录时要清掉 token别只跳转不清理不然用户再进来还是登录态会有我明明退出了怎么还进去了的困惑。同时后端可以考虑做一个黑名单把退出或者异常的 token 拉黑不过这就引入了状态看你的安全要求取舍。6. 联调排错实录我踩过的那些坑6.1 常见问题速查表联调阶段的问题基本就那么几类我整理了一张表遇到现象直接对号入座现象大概率原因排查方向验证码一直提示错误前端传的字段名和后端不一致看请求体 key 是否匹配验证码明明收到了却说已过期存和取的 key 拼法不一致检查 key 模板和手机号是否带空格本地能跑测试环境发不出短信短信 Key 或模板 ID 没配检查环境变量和配置中心用户点两次出现两个账号并发注册没唯一索引兜底给手机号加唯一索引倒计时数字乱跳setInterval 未清理或减一方式不准改用时间戳计算登录成功但接口 401token 没带上或前缀不对检查拦截器和 Bearer 前缀这张表里验证码存和取 key 不一致是最阴的一种代码看着没问题就是查不出来。我曾经因为手机号参数没 trim存的时候带了个尾部空格取的时候没有排查了快一个小时。后来我养成习惯所有手机号入口统一做一次trim()和格式校验。6.2 跨域和凭证相关的坑前后端分离开发时前端跑在 5173后端跑在 8080一定会遇到跨域。后端用CrossOrigin或者配置全局的 CorsFilter 都能解决。但如果你用的是 Cookie 存 token情况会复杂一点前端 axios 要设置withCredentials: true后端的Access-Control-Allow-Origin就不能用*必须明确写前端域名同时Access-Control-Allow-Credentials要设为 true。这几个配置是绑定的少一个就失败而且浏览器控制台的报错信息很含糊容易绕圈子。我的经验是开发环境直接用 Vite 的代理把请求转发到后端绕开跨域比配一堆 CORS 头省心得多// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, }生产环境前后端同域部署的话跨域问题根本不存在这也是我推荐的部署方式用 Nginx 把静态资源和 API 挂在同一个域名下配置简单、安全策略也少踩坑。6.3 上线前检查清单这套东西开发完之后别急着上线对着下面的清单过一遍短信 Key、模板 ID、签名是否都从配置中心读取代码里没有硬编码。三层限流是否都生效压测一下超过阈值是否真的被拦截。验证码键和限流键是否都有 TTL用ttl命令在 Redis 里确认一遍。手机号是否有唯一索引并发注册测试是否只产生一条数据。日志中的手机号是否脱敏验证码本身绝对不能进日志。token 过期后的处理是否友好是跳登录页还是提示刷新。前端倒计时组件卸载时定时器是否清理反复进出页面验证一下。这份清单里我特别想说验证码进日志这一条。有些同事为了排查方便在发码和校验时直接把验证码打进 log本地开发时确实省事但一旦这个日志文件被采集到日志平台等于把所有用户的登录凭证公开了。正确做法是打掩码或者只打验证码已生成这种事实不打具体值。最后分享一个我们在实际项目里做的优化把验证码发送做成渐进式惩罚而不是硬拒绝。同一个手机号第一次发免费第二次要求间隔 60 秒第三次要求先过图形验证码。这样正常用户几乎无感而脚本刷子的成本会被逐步抬高。上线之后短信成本比之前硬限流方案降了大概三成同时因为正常用户不再频繁撞到发送过于频繁的提示客诉也少了。这套逻辑不复杂就是在限流挡之前多查一次计数超过阈值再返回一个需要图形验证的标记前端收到这个标记就弹个滑块体验和成本都兼顾到了。
返回列表