尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

短信验证码登录实战:从架构设计到防刷风控全解析

短信验证码登录实战:从架构设计到防刷风控全解析 1. 为什么大家都用短信验证码登录需求背景与整体架构我大概是从2018年开始接手短信验证码登录相关的功能那会儿很多App还处于“账号密码登录 第三方微信授权”的阶段。到了今天短信验证码登录几乎成了所有C端产品的标配甚至很多B端后台也把它作为第二认证方式。先说清楚这个东西到底解决了什么问题。账号密码登录最大的痛点不是技术复杂度而是“密码管理”。普通用户不会为你的产品单独记一个密码他们要么用弱密码要么一个密码走天下要么直接忘记。短信验证码登录绕开了这一整套问题——手机号天然就是账号验证码天然就是凭证用户不需要记忆任何额外信息交互成本最低。从产品角度看短信验证码登录还有一个隐藏价值直接拿到用户的真实手机号。这个手机号后面能挂在一堆业务上比如消息通知、安全提醒、二次验证、营销触达。所以很多产品宁肯放弃密码登录也要把短信登录放在首位。我再把一条完整的验证码登录链路拆出来方便你建立全局观用户输入手机号点击“获取验证码”前端先做一次手机号格式校验格式不对直接拦截后端接收请求检查该手机号是否在冷却期内比如60秒内不能重复发后端生成6位随机数字验证码存储到Redis设置过期时间通常5分钟后端调用短信服务商接口把验证码发送到用户手机用户输入收到的验证码提交登录后端从Redis读取验证码比对成功则删除失败则处理错误计数校验通过后后端签发登录令牌JWT Token或Session返回给前端前端保存令牌后续所有接口请求带上它登录闭环完成这套流程看起来简单但每一步展开都有细节。我拿实际做的项目来拆解用的技术栈是Spring Boot Redis 阿里云短信服务前端是Vue。你不用拘泥于这个组合只要理解了核心逻辑换成Go、Node.js、腾讯云、极光推送都行思路是相通的。1.1 验证码登录解决的核心问题从技术视角看验证码登录的核心不是“发短信”这件事本身而是身份认证的降级与重建。降级是指把“用户记忆密码”这个心智负担降为“接收一次性动态口令”重建是指每一次登录都重新建立一条新的信任链路。实际开发中你还会发现它天然支持一种用户增长场景——无感注册。用户第一次用手机号验证码登录时如果数据库里没有这个手机号对应的账户后端可以直接为他自动注册一个账户用户不需要填昵称、设密码、传头像。这些信息可以后续在个人中心慢慢补齐。这也是为什么短信验证码登录在拉新转化率上比密码注册高出不少。1.2 一条完整的验证码登录链路上面那9步流程我在项目里拆成两个核心接口POST /api/sms/send发送验证码POST /api/auth/login校验验证码并登录两个接口一前一后职责完全分离。发送接口只负责“发出去”登录接口只负责“验回来”。这样做的好处是后续如果要做语音验证码、邮箱验证码、甚至外国人手机号验证码不需要改动登录主体逻辑新增一个渠道类型就行。后端存储层面我用的是Redis。很多人会问验证码存数据库表不行吗也能行但你会遇到三个麻烦第一验证码有天然的生命周期过期时间要手动清理第二高频读写验证码字段会给数据库带来没必要的压力第三验证码校验后需要立即删除这个“一条记录只活几分钟、用完即焚”的场景简直像是给Redis量身定做的。1.3 技术选型我为什么用这套组合细说下我的选型理由。后端用Spring Boot主要看重生态成熟。短信服务商SDK在Java体系里最稳遇到问题网上资料也最多。当然这不是说别的语言不行我只是觉得团队上手成本和维护成本最低。Redis用来存验证码核心原因是TTL特性和原子操作。一条验证码的完整生命周期就是“写入→过期/校验后删除”这个模型天然契合Redis。后面我会详细讲Key设计和边界情况。短信服务商选阿里云主要因为当时阿里云短信稳定性和到达率最优而且国内手机号覆盖率好。这个环节的选择直接影响到短信到达延迟和费用值得单独说一章。前端就没什么特别的Vue Axios重点在按钮倒计时、输入框自动聚焦、错误提示这些交互细节上。2. 短信服务商怎么选从免费额度到发送到达率的真实对比短信服务商是验证码登录绕不开的外部依赖。它的质量直接决定了用户的登录体验——服务商接口挂了用户收不到短信你再怎么优化后端逻辑都没用。这一节我把自己这些年接触过的几家主流服务商情况拿出来聊聊。2.1 主流服务商横向对比国内主流的短信服务商主要有阿里云、腾讯云、华为云、网易云信、极光推送短信业务、容联云通讯等。我做了一张对比表列的是我直接用过或深度调研过的情况服务商到达率口碑国内覆盖度对接复杂度价格区间以验证码短信为例附加能力阿里云高大厂背书好三网全覆盖低SDK完善按量计费新用户有免费额度支持震动模板、异常检测、日发送量统计腾讯云高好微信生态联动低和阿里云基本持平支持短信签名授权、子账号权限粒度细华为云中上好中略便宜企业级客户更友好网易云信中上好中中海外通道比较强容联中中中便宜语音验证码方案齐全表格里的价格参考意义不大因为各家活动不同而且大客户都能拿到折扣。真正影响决策的其实是几个隐性因素。第一是签名和模板审核速度。每个短信服务商都要求你先申请“短信签名”比如【XX科技】和“验证码模板”比如您的验证码为${code}${minutes}分钟内有效审核通过才能发短信。阿里云的审核一般比较快资料齐全的情况下一小时到半天。这看似不重要但在项目上线前几天如果还没通过审核那就非常被动了。第二是到达率的稳定性。各家都宣称“直达率99%”但真遇到营销短信高峰期通道拥堵会导致到达延迟甚至丢失。我见过最离谱的情况是某小服务商在双十一晚上短信发送延迟超过20分钟用户等得火冒三丈。大厂虽然也会延迟但整体稳定性高出一个量级。第三是异常告警能力。比如单日发送量突然飙升、模板被恶意刷、短信被运营商拦截服务商有没有对应的监控和告警有些小服务商只管“发出去”异常全靠你自己发现。阿里云和腾讯云在这方面做得算比较全的有控制台可看每日发送量和失败明细。2.2 我最后的选型依据我的建议是验证码这类高实时性业务优先选阿里云或腾讯云不要为了省几厘钱去赌小服务商的通道质量。验证码登录是产品的第一道门这个环节省下来的成本最后大概率会在用户投诉、流失上面加倍还回去。补充一个技巧短信服务商可以配置“多通道冗余”。主通道挂了自动切到备选通道这个能力在大流量场景很重要。阿里云和腾讯云都支持这种容灾配置但需要额外花钱一般中型项目用不到。如果你只是做一个小项目选一家靠谱的服务商就够了不必过度设计。另外别忽视国际短信的问题。如果你的产品海外用户多需要选支持国际短信的服务商阿里云和腾讯云都有国际短信服务但也意味着你要准备英文模板。如果只是国内App下单前记得确认服务商送的免费额度是否包含国内通用号码。3. 验证码该存哪里Redis Key设计与其他方案的取舍验证码的存储是整个登录功能里最值得花时间设计的点。很多初学者把验证码放在数据库表里或者干脆放在后端进程的内存Map里。我先说结论业务正式上线后用Redis TTL是正确姿势。下面详细讲为什么以及Key怎么设计。3.1 为什么首选Redis假设你把验证码存到MySQL表里表结构大概是CREATE TABLE sms_code ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL, code VARCHAR(10) NOT NULL, scene VARCHAR(20) NOT NULL, expire_at DATETIME NOT NULL, used TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这个设计能做但有个问题验证码是“高写、短生命周期”数据每条记录生成后要么被消费掉要么过期变成垃圾。你还需要定期清理expired_at小于当前时间的记录否则表会越来越大。更麻烦的是如果一天发了几十万条验证码MySQL单表写入会成为一个瓶颈点还要考虑索引优化、分库分表。Redis的方案就干净得多。每条验证码设置过期时间到期自动消失不需要额外清理任务。单值读写O(1)高并发下性能表现好。而且Redis有SETEX这样的原子命令做到“写入 过期时间”一步到位避免中间态。打个比方数据库方案像是在小区公告栏贴通知过期了还得专门派人去撕掉Redis方案像是给每张通知贴了自动溶解的贴纸时间到了自己消失。显然更省心。3.2 Key设计与存储结构Redis Key的设计我用的是这个格式sms:code:{scene}:{phone}其中scene表示业务场景比如登录用login、注册用register、找回密码用reset。为什么要加场景前缀因为同一个手机号在同一个时间段可能同时触发了“登录”和“找回密码”如果不加场景区分两个流程会互相覆盖验证码导致用户收了一个码用在另一个地方时死活验证不过。Value部分存的不是单纯的数字验证码。我存的是一个JSON或Hash结构大概长这样{ code: 482913, createdAt: 1734567890, attempts: 0, lastSentAt: 1734567890 }code验证码createdAt生成时间attempts错误尝试次数lastSentAt最近一次发送时间把尝试次数放在Redis里是实现“防爆破”的重要依据用户连续输错5次不管验证码对不对都直接作废。后面安全章节我会再展开。过期时间设置我一般是5分钟。这个时间不是拍脑袋定的它要平衡两个因素太短会导致用户还没输入完就过期体验差太长会增加被爆破的风险。5分钟在绝大多数场景下是合理的。有些产品会放宽到10分钟但我不建议因为验证码被盗用在5分钟后概率会显著上升。3.3 其他方案对比如果项目还处于原型阶段没有Redis也可以先用进程内的ConcurrentHashMap顶一下。但有两个明显的坑第一重启即丢失第二多实例部署时用户在A实例上发了验证码请求打到B实例上就查不到。所以这个方案只适合本地开发联调。还有一个方案是使用内存缓存框架如Caffeine并且设置expireAfterWrite偶尔用用还行。但一旦上到多实例或者需要跨服务共享验证码状态还是得回归Redis。顺带说一句如果你用的是Redis集群要注意不同实例之间的Key访问。验证码的Key要保证同一个手机号的读写都在同一个Redis节点上否则会出现“写入后读取不到”的问题。Redis Cluster会对Key做CRC16分片只要你用相同的Key访问不考虑加Hash Tag通常不会踩坑。4. 发送验证码接口从参数校验到频控的完整实现接下来是最核心的部分——发送验证码和校验登录的具体实现。我先写发送接口这个接口看似简单真正做好需要覆盖很多边界场景。4.1 接口协议定义先定义接口协议POST /api/sms/send Content-Type: application/json { phone: 13812345678, scene: login }参数很少但下面这些校验一个都不能少phone必须符合大陆手机号正则^1[3-9]\d{9}$不合法直接返回400scene不能为空且必须在后端白名单里防止有人传一个你根本没用过的场景号可选参数reCaptchaToken在风控要求高的时候做图形验证码或滑块验证4.2 发送接口实现含代码整个发送逻辑的代码流程大致是这样PostMapping(/api/sms/send) public ResultVoid sendSmsCode(RequestBody Valid SendSmsRequest request) { String phone request.getPhone(); String scene request.getScene(); // 1. 手机号校验 if (!PhoneUtil.isValid(phone)) { return Result.fail(手机号格式不正确); } // 2. 冷却时间检查同一手机号 同一场景 60秒内只能发送一次 String cooldownKey sms:cooldown: scene : phone; Boolean hasCooldown redisTemplate.hasKey(cooldownKey); if (Boolean.TRUE.equals(hasCooldown)) { return Result.fail(发送太频繁请稍后再试); } // 3. 生成6位随机验证码 String code String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); // 4. 写入Redis过期时间5分钟 String codeKey sms:code: scene : phone; MapString, Object value new HashMap(); value.put(code, code); value.put(createAt, System.currentTimeMillis()); value.put(attempts, 0); redisTemplate.opsForHash().putAll(codeKey, value); redisTemplate.expire(codeKey, 5, TimeUnit.MINUTES); // 5. 设置冷却标记 60秒 redisTemplate.opsForValue().set(cooldownKey, 1, 60, TimeUnit.SECONDS); // 6. 调用短信服务商发送 SmsRequest smsRequest new SmsRequest(); smsRequest.setPhone(phone); smsRequest.setTemplateCode(SMS_123456); smsRequest.setTemplateParam({\code\:\ code \}); smsResult smsProvider.send(smsRequest); if (!smsResult.isSuccess()) { // 发送失败要回滚冷却时间避免用户等60秒才能重试 redisTemplate.delete(cooldownKey); return Result.fail(短信发送失败请重试); } // 7. 记录发送日志手机号脱敏 smsLogService.record(phone, scene, SmsStatus.SUCCESS); return Result.success(); }这段代码里有一个需要特别强调的点发送失败时一定要删除冷却Key。如果忽略这一步短信服务商接口临时故障用户点击获取验证码得到的永远都是“发送太频繁”等60秒再点又失败体验非常差。这个细节我在项目里真实踩过不仅用户投诉连运维都以为服务挂了。生成验证码的方式用ThreadLocalRandom即可没必要上更复杂的随机数方案。验证码本身有效期短、单次性、有频控保护安全性足够。4.3 发送频控不只是同一个手机号限流频控这个事很多版本只做上面代码里的“60秒冷却”其实不够。作为登录入口短信发送是网络黑产最爱的攻击目标之一。完整的频控策略建议分三层第一层单手机号维度。同一手机号60秒只能发送1次、24小时最多发送10次、7天最多发送30次。这几个阈值用Redis的INCR配合过期时间即可实现比如每日计数Keysms:count:daily:{date}:{phone}设置48小时过期。第二层单IP维度。同一IP地址每10分钟最多发送20次验证码请求。这个能有效拦截用脚本换手机号刷短信的恶意行为。用户在公司Wi-Fi下有多个同事同时登录触发上限时稍微放宽阈值到30次比较友好。第三层全局维度。整个服务同一秒最大发送量做限制防止服务商账号被流量打爆。一般服务商侧有默认限流阈值比如单日10万条你需要在本地提前预估一个安全值。这三层顺序执行任一命中就返回“操作频繁”。注意频控的返回文案要模糊处理不要告诉攻击者到底触发了哪层限制。统一提示“操作过于频繁请稍后再试”就够了。5. 校验登录接口验证码判断与令牌签发的关键细节发送接口负责把验证码送到用户手里登录接口负责把验证码换成一个合法的登录态。这个接口同样有很多容易出错的细节。5.1 登录流程的校验顺序我的校验逻辑按这个顺序执行手机号格式校验和发送接口共用同一个工具类从Redis取验证码取不到直接返回“验证码已过期”比对验证码不一致则尝试次数1尝试次数达到上限删除验证码Key比对通过后删除验证码Key一次性使用查询用户表存在则登录不存在则自动注册签发JWT令牌返回前端这个顺序是固定的不要反过来。比如你先把用户查出来再校验验证码就会让攻击者通过接口响应时间差来判断一个手机号是否已经注册——这是一个很细微但真实存在的用户枚举漏洞。5.2 核心校验代码代码实现PostMapping(/api/auth/login) public ResultLoginResponse login(RequestBody Valid LoginRequest request) { String phone request.getPhone(); String code request.getCode(); // 1. 手机号校验 if (!PhoneUtil.isValid(phone)) { return Result.fail(手机号格式不正确); } // 2. 获取验证码 String codeKey sms:code:login: phone; MapObject, Object cache redisTemplate.opsForHash().entries(codeKey); if (cache.isEmpty()) { return Result.fail(验证码已过期请重新获取); } // 3. 防爆破检查尝试次数 int attempts Integer.parseInt(String.valueOf(cache.getOrDefault(attempts, 0))); if (attempts 5) { redisTemplate.delete(codeKey); return Result.fail(验证码已失效请重新获取); } // 4. 比对验证码 String realCode String.valueOf(cache.get(code)); if (!realCode.equals(code)) { attempts; redisTemplate.opsForHash().put(codeKey, attempts, attempts); if (attempts 5) { redisTemplate.delete(codeKey); } return Result.fail(验证码错误); } // 5. 验证通过立即删除验证码 redisTemplate.delete(codeKey); // 6. 查询用户不存在则自动注册 User user userService.findByPhone(phone); if (user null) { user userService.registerByPhone(phone); } // 7. 签发JWT String token jwtUtil.generateToken(user.getId(), user.getPhone()); return Result.success(new LoginResponse(token, user)); }上面第3步的防爆破逻辑很关键。第4步里“错误一次计数加一次”达到5次直接删Key。这样攻击者就不能无限爆破6位验证码了。6位数字总共100万个组合没有次数限制的话脚本跑几分钟就能试出来。加了5次限制后攻击者每试5次就必须重新触发短信成本大幅上升。5.3 关于Token方案的取舍登录成功后返回给前端的是一个JWT字符串。为什么不用Session原因有三点无状态。验证码登录针对的是“短会话”场景JWT自带用户信息后端不需要单独维护Session存储也不用处理Session同步问题多端友好。同一个用户在App、H5、小程序同时登录JWT可以互不影响扩展性好。后续要做SSO单点登录、网关鉴权时JWT的通用性更强JWT的坑也有最大的坑是无法主动失效。用户点“退出登录”后只要他手里的JWT没过期理论上还能请求后端。如果你的业务对安全性敏感可以在Redis里维护一个JWT黑名单登出或异常时把jti加进黑名单。我做过简化版生成JWT时把会话ID作为jti写入Redis设置过期时间与JWT同步每次鉴权时检查Redis里是否存在这个jti。这样可以做到吊销效果代价是多一次Redis查询但对绝大多数系统可接受。jti的全称是JWT ID它是JWT标准里用来唯一标识一个Token的字段相当于给每个Token发了一张身份证号吊销Token时只需要把这个ID加入黑名单即可不用遍历所有在线Token。下称会话ID。6. 前端交互点击、倒计时、错误提示这些容易被忽略的小事后端接口做得再稳前端交互体验拉胯用户同样会吐槽“这个登录功能不行”。短信验证码登录的前端我把它拆成三个重点来打磨。6.1 获取验证码按钮的状态流转对前端来说获取验证码按钮有三个状态可点击、倒计时中、不可点击。核心的倒计时逻辑我建议用前端本地倒计时 后端冷却时间双重控制。前端把倒计时设成60秒时间到了就恢复可点击后端也设60秒冷却如果前端的倒计时跑偏了比如用户改了系统时间请求发过去会被后端拦下来前端再做一次错误提示并重新拉回倒计时状态。代码如下const COOLDOWN_SECONDS 60; async function sendCode() { const phone phoneInput.value; if (!/^1[3-9]\d{9}$/.test(phone)) { showError(请输入正确的手机号); return; } if (countdown 0) return; try { const resp await axios.post(/api/sms/send, { phone, scene: login }); if (resp.data.code 0) { startCountdown(); } else { showError(resp.data.message); } } catch (e) { showError(网络异常请重试); } } function startCountdown() { countdown COOLDOWN_SECONDS; sendCodeButton.disabled true; sendCodeButton.textContent countdown s后重发; const timer setInterval(() { countdown--; sendCodeButton.textContent countdown s后重发; if (countdown 0) { clearInterval(timer); sendCodeButton.disabled false; sendCodeButton.textContent 获取验证码; } }, 1000); }倒计时的起点是发送成功之后不是点击按钮之后。如果短信服务商返回失败前端不应该进入倒计时应该直接提示用户重试。6.2 验证码输入框的交互细节验证码输入框和密码输入框的交互习惯不同。我总结了几条实战经验第一输入完成自动收起键盘。6位验证码输入到位后前端自动触发登录请求而不是让用户再点一次“登录”按钮。这个细节能明显提升转化率。实现方案是监听input事件当value长度等于6时自动提交。第二粘贴支持。很多短信App可以一键复制验证码iOS还可以自动从短信中提取验证码填充。前端需要做好输入框的监听让粘贴的验证码也能触发自动提交。第三错误提示放在输入框下方不要用弹窗。用户输错验证码后需要能看到错误原因同时保留已经输入的手机号只清空验证码输入框。这样用户只需要重新输入验证码不用从头再来。第四自动聚焦到下一个输入框。如果是单框输入整串验证码就无所谓如果是分6个小框需要在每个格子输完后自动聚焦到下一个格子。这个交互看着简单但很多前端小哥哥小姐姐第一次做都会漏掉。6.3 App端与Web端的天生差异在App端实现短信验证码登录时有一个Web端没有的体验iOS和Android系统级短信自动填充。iOS通过document的autocompleteone-time-code属性实现短信验证码自动填充Android的Smart Lock也类似。原生App可以接入系统API比如iOS的UITextField的textContentType .oneTimeCodeAndroid的SmsRetrieverAPI。如果你的登录页在App里用WebView承载则H5侧也会退化为手动输入。移动端相比PC端还有一个特点手机号键盘要设置为数字键盘输入框inputmodenumericmaxlength11避免用户还要切换键盘。坦白讲这些交互细节不会写在后端接口文档里但对用户体验的改善是实打实的。我见过太多项目后端做得无可挑剔前端却因为按钮不能倒计时、错误提示不友好、自动聚焦缺失被测试提了一堆优化单。7. 防刷与风控短信接口绕不开的安全功课短信验证码登录本质上是用“短信通道”作为安全边界那么短信通道本身就成了攻击目标。如果防刷做得不好轻则被薅短信费用重则账号体系被撞库、恶意注册。这一节我专门讲安全。7.1 验证码爆破与“撞库”怎么防“爆破”指的是攻击者用脚本穷举验证码因为6位纯数字只有100万种组合网络请求够快的话理论上很快就能试完。针对这个前端登录接口做了“尝试次数限制”但只靠这个还不够原因在于攻击者可以同时用成千上万个手机号来爆破同一个验证码。比如攻击者提前批量获取了100万个手机号的验证码然后写脚本对每个手机号都从000000试到999999。如果每次校验都打满5次后端要防的就是“单用户5次”和“整体请求量”两个维度。我建议的做法是再加一个登录接口的IP限流同一IP每分钟最多100次登录尝试。普通用户几乎永远达不到这个阈值但对脚本机器人是硬性限制。IP限流用Redis实现String ipLimitKey sms:limit:login:ip: getClientIp(); Long count redisTemplate.opsForValue().increment(ipLimitKey); if (count 1L) { redisTemplate.expire(ipLimitKey, 1, TimeUnit.MINUTES); } if (count 100) { return Result.fail(操作频繁请稍后再试); }另外在同一场景下同一个手机号如果请求登录超过10次未通过就把它拉进一个临时黑名单30分钟内禁止登录。这个黑名单同样用Redis实现过期时间到自动解除。7.2 短信轰炸怎么治“短信轰炸”是另一个常见攻击攻击者利用某个公开页面不需要经过图形验证码校验的后端漏洞以任意手机号为受害者批量触发短信验证码发送。受害者手机在短时间内会收到大量不同平台的验证码短信。这个问题的根源往往不只是你自己的接口而是你提供了一个“输入手机号就能收到短信”的免费入口。要防住必须要做到几件事第一发送验证码前必须经过人机校验。图形验证码、滑块验证、行为验证都行。推荐的方案是在前端集成阿里云的无痕验证或腾讯防水墙根据后端返回的验证结果决定是否放行短信发送。人机校验会引入额外的前端SDK和后端调用但对防轰炸至关重要。第二对发送频率做“全场景全局限流”。上面提到的三层频控重点是全局维度。一旦发现某个手机号在多个scene下被频繁请求果断拉黑。第三设置单日单手机号发送上限。比如24小时内最多10条。这个阈值不能太高太高防不住轰炸太低会误伤用户。10条是行业常用值碰到要重新验证的场景比如连续登录失败触发再次发送也基本够用。第四关注短信服务商侧的报警。阿里云和腾讯云控制台都能看到每日发送趋势和失败详情。如果发现短信发送量突然异常上涨优先检查是不是某个时间点涌入了大量请求而不是先怀疑用户量涨了。7.3 日志脱敏与审计这一块容易被忽略但做安全审计时经常被查。短信验证码相关的日志无论后端日志还是数据库日志都不允许记录完整验证码。原因很简单日志的保存周期通常比验证码有效时间长一旦日志泄露攻击者可以用验证码完成登录。我实际落地的做法是日志只记录发送状态、手机号脱敏138****4567、场景、时间验证码只存在于Redis且加密存储即使Redis被拖库也无法直接使用登录成功日志记录IP、设备、时间便于后续做异常行为审查登录失败日志记录失败原因和IP连续失败触发告警有些团队会把验证码加密后再存Redis虽然Redis本身可以设置密码和网络隔离但多一层加密总归更安全。我用AES对称加密密钥放在配置中心而不是代码里或者Redis配置文件里。8. 踩坑实录那些只有线上环境才会暴露的问题最后这部分我挑选了几个真实项目中遇到的坑每一个都曾经折磨过我或我的团队。希望你能绕开这些不用像我一样交学费。8.1 短信发送成功但用户收不到这个坑出现的频率远比你想象中高。有一次线上反馈说“大批用户收不到验证码”排查链路是这样的第一看后端日志确认短信服务商接口返回的是成功。果然日志显示发送成功。第二看服务商控制台的发送详情。灵异的地方来了控制台也显示发送成功回执码也是“DELIVRD”——投递成功。第三让用户查看垃圾短信箱。反馈来了用户在“通知类信息”里找到了短信很多安卓手机对验证码短信做了分类如果你的短信内容里有“验证码”“动态口令”等关键词很可能被手机系统或安全软件拦截。最后定位到的原因不在短信内容而在短信模板的签名。我们当时的短信签名和App名称不一致运营商的通道到达率受签名影响很大。签名和App名称一致、且模板规范化能明显减少被运营商拦截的概率。这给我们留下的教训是“发送成功”不等于“用户收到”。做短信登录后必须建一条监控短信到达率的告警一旦到达率跌破一个阈值比如95%就要主动排查而不是等用户来投诉。8.2 验证码过期时间与重发逻辑的冲突这是个很隐蔽的逻辑问题。假设我设置了验证码5分钟过期同时冷却时间60秒。用户第一次点击获取验证码后过了59秒发现短信没收到其实是延迟又点了一次被冷却挡住。等到60秒后再次点击后端重新生成验证码覆盖了原来的Key。但如果第一次的短信只是延迟了用户在60秒后收到的是“第一个验证码”后来他输入第一个验证码却一直提示错误因为Redis里存的已经被第二个覆盖了。这个问题的根因是同场景多次发送会覆盖旧验证码。处理的办法有两种方案A同一个手机号同场景的有效验证码还活着时再次发送不覆盖直接复用旧验证码并重置过期时间。但这样会延长验证码的有效期安全性下降。方案B每次发送都会在短信文案里带上如非本人操作请忽略让用户确认手机收到的是最新的验证码。但用户只会觉得是产品逻辑混乱。我采用的是折中方案同一场景下如果旧验证码还没过期再次发送时沿用同一个验证码只在用户点击“获取”后刷新过期时间。冷却期结束后允许重发但验证码本身不变。这样做不会出现“两个验证码冲突”的问题安全性不受影响因为验证码仍然是一次性且短时有效的。8.3 并发重复点击导致多条短信扣费这个坑在新手项目里极其常见。前端虽然做了倒计时但恶意用户或网络波动导致同一请求被重复提交时后端如果没有幂等处理用户点一次“获取验证码”按钮实际上发出了两三条短信。我的处理思路是利用Redis的SETNX命令做分布式锁。同一个手机号同场景的体验是第一个请求拿到锁并发送短信第二个请求进来发现锁已存在直接返回“操作频繁”不调用短信服务商。String lockKey sms:lock: scene : phone; Boolean acquired redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(acquired)) { return Result.fail(操作频繁请稍后再试); } // 继续执行发送逻辑...锁的过期时间设置为10秒覆盖短信服务商正常响应时长就够了。如果服务商响应超过10秒锁自动过期用户可以再次尝试不会卡死。8.4 测试环境不要真发短信开发联调时如果每次都真的把短信发到测试同学手机号上不仅影响测试效率还会因为频繁发送触发频控导致功能流程被卡住。我们项目的做法是通过Spring Profile区分环境。在dev和test环境不调用短信服务商直接把验证码输出到后端日志和控制台同时在前端页面以红色提示“测试验证码482913”。这样开发同学随便点不用看手机端到端联调照样能跑通。生产环境则增加一个“测试账号白名单”——只有白名单里的手机号才能真正收到短信。这样上生产验收时不会因为频繁触发频控而把真实的营销短信额度消耗掉。另外我建议在配置中心加一个开关比如sms.mock.enabled。这个开关在出现短信服务商故障时可以临时打开让系统降级到“验证码输出到日志”的方式先保证功能可用等服务商恢复后再切换回去。这个预案听起来有点激进但真的能救急。8.5 时区与时间不一致导致的过期判断错误最后这个坑比较冷门。当时我们的Redis服务器和应用服务器的系统时间不同步应用生成的createAt和Redis的TTL计时出现了偏差导致同一份验证码在应用层面判断“已过期”但Redis里还存活着。用户看到“验证码已过期”后重新获取又因为频控被拦住。排查了半天最后发现是云服务器NTP同步服务没开时间差了近两分钟。解决方式很简单所有服务器统一配置NTP同步代码里判断过期时间时统一用Redis的TTL为准不要自己用System.currentTimeMillis()和createAt去算。Redis的TTL是权威时间因为过期是Redis自己执行的你和它纠结“时间对不对”没有意义直接信它就对了。写到这里我其实还没有把短信验证码登录的所有细节都说完比如多语言短信模板、手机号换绑时的验证码场景、分布式事务如何处理“自动注册 发送短信”的原子性等等。但上面这些内容已经足够支撑你从零到一实现一个能上生产环境的短信验证码登录功能。最后再补一句我反复踩坑后得出的体会这一套流程真正考验人的地方从来不是“怎么把验证码发出去”而是把发送、存储、校验、防刷、前端体验这些环节串成一个闭环后还能在边界条件和异常场景下保持稳定。
返回列表