ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手机号验证码登录全流程实现:从短信接入到前端闭环

手机号验证码登录全流程实现:从短信接入到前端闭环 做后端的朋友八成都有过这种经历产品经理跑过来说注册登录功能不是很简单吗半天应该够了吧结果真上手才发现要处理验证码、并发、防刷、连接超时种种问题简单二字背后其实是一条非常成熟的链路。手机号注册登录是互联网产品最基础也最高频的能力之一无论是App、小程序还是Web端几乎离不开它。这篇内容要讲的就是一套能三分钟跑通的实现思路——不是三分钟写完所有代码而是三分钟让你掌握从短信接入、后端接口到前端闭环的完整路径并且知道哪些地方藏着坑、为什么那么设计。照这套方案哪怕你之前没做过这个功能也能在一个晚上之内稳定落地且上线后大概率不会被打回来说安全不行。1. 为什么手机号验证码登录成了标配先弄懂核心逻辑1.1 验证码登录vs账号密码登录胜负不在体验很多人以为手机号验证码登录的流行是因为方便其实这只是表面原因。真正让团队愿意放弃账号密码登录的是它把注册、登录、找回密码、身份验证四个场景压缩成了一个接口。账号密码体系看起来很经典但每一个环节都是成本注册页面要确认密码要防止弱密码要做密码强度校验用户忘记密码要设计找回流程、邮件/短信重置密码泄露后要处理脱库、撞库风险第三方登录要对接OAuth流程和用户信息授权验证码登录把这些全砍掉了。用户输入手机号、收到验证码、填进去系统查一下有没有这个用户——没查到他就是新用户自动创建账号查到了这一步就是登录。一个接口两种身份完美解决先有鸡还是先有蛋的问题。1.2 一次完整的验证码登录流程要经过哪些环节这一步必须搞清楚因为很多新手在写代码时把流程想浅了只写了发码—验码—登录三步结果安全上漏洞百出。我们拆细来看用户在前端输入手机号点击获取验证码前端先做一次手机号格式校验不合法直接提示后端接收请求校验手机号格式检查该手机号是否已在短时间内发送过验证码限流后端生成一个6位随机数字验证码以手机号为key存入Redis设置5分钟过期时间后端调用短信服务商的接口把验证码推送给用户用户收到短信将验证码输入页面点击登录后端从Redis取出该手机号对应的验证码与用户提交的做比对比对通过后立即删除Redis中的验证码一次性使用后端用手机号查询用户表不存在则创建新用户这一步就是自动注册后端签发tokenJWT返回给前端前端保存token后续所有请求带上这个token这里面最核心的设计理念是验证码是一次性凭证token是持续性凭证。验证码负责证明你拥有这个手机号token负责证明你是这个用户。两者职责不同生命周期也不同不能混用。另外我特别想强调第8步——验证成功后删除缓存。这个动作很多初学的人会漏掉但它是防止验证码重放攻击的关键。如果验证码能在同一时间窗口里被反复使用那黑客就可以用一次获取的验证码多次操作账号。2. 短信服务接入三步走选型、开通、发码2.1 不选贵的只选对的主流短信服务商怎么挑先把话说在前面短信服务本身是收费的国内主流的云厂商单价差别不大真正影响你选择的是接入难度、审核速度和稳定性。我整理了个对比表按个人开发者和中小团队的视角来的服务商优势个人接入难度价格参考阿里云短信市场份额大、文档全、SDK覆盖语言多需实名认证个人可接入审核周期通常1小时内约0.045元/条腾讯云短信控制台易用微信生态配合好个人需实名签名审核较快约0.05元/条华为云短信企业客户体验好稳定性强个人接入稍有门槛略高于前两家聚合类短信平台不需要自己申请签名模板开箱即用最容易但单价高、质量参差约0.06~0.1元/条如果你只是做Demo验证、或者给企业做内部系统聚合类平台确实省事。但如果是面向用户的正式产品我还是建议用阿里云或腾讯云。原因不单是价格而是主流云厂商的消息通道质量更稳定到达率更高回执查询、投诉举报处理这类配套也更完整。2.2 开通短信服务最容易被卡的两个点我见过不少人卡在短信服务开通这一步其实不是技术问题是流程问题。第一个坑是签名审核。短信签名就是用户收到消息时看到的【XX科技】这个方括号里的名称。申请签名时需要提供用途说明个人开发者需要提供APP、小程序或网站的名称。这里有个技巧云厂商对验证码类用途的签名审核放得最松因为你明确说了就是发验证码它会把风险归类为最低档。千万别写着营销推广那审核周期会拖死你。第二个坑是模板审核。短信模板不是随便写的比如您的验证码是${code}请勿泄露这种格式。你需要注意不同云厂商对变量的写法有差异有的用${code}有的用{code}照着官方文档写就行。最容易犯错的是在模板里放了两个变量比如您的验证码是${code}${minute}分钟内有效部分平台会限制只能有一个变量这时你就要把时长写死在文案里。2.3 发送验证码的最小代码阿里云短信示例这里我用阿里云的官方SDK演示Node.js版本。为什么用Node.js因为它在快速开发个人项目时最能体现三分钟的效率而且语法直观。const Dysmsapi require(alicloud/dysmsapi20170525); const { Config } require(alicloud/openapi-client); // 配置AccessKey生产环境务必用环境变量注入别写死在代码里 const config new Config({ accessKeyId: process.env.ALIYUN_AK, accessKeySecret: process.env.ALIYUN_SK, endpoint: dysmsapi.aliyuncs.com }); const client new Dysmsapi.Client(config); async function sendSms(phone, code) { const req new Dysmsapi.SendSmsRequest({ phoneNumbers: phone, signName: 你的签名名称, templateCode: SMS_你的模板Code, templateParam: JSON.stringify({ code }) }); const resp await client.sendSms(req); if (resp.body.code ! OK) { throw new Error(短信发送失败: ${resp.body.message}); } return resp; }这个函数封装好后后面所有业务场景都能复用。我提醒一点发送短信是异步的服务商返回OK只代表它受理了你的请求不代表用户已经收到。这就是为什么线上短信偶尔有延迟或者丢失如果业务对送达率要求高还需要对接短信回执查询接口做补偿。3. 后端核心逻辑把注册和登录合成一个接口的完整实现3.1 验证码生成与存储为什么我偏爱Redis验证码本身没有太高的技术含量生成六位随机数就完事了。但存储方案我要多说两句。唯一正确的生产级选择是Redis。验证码的天然特点是短时效、单键访问、需要自动过期Redis的SET key value EX seconds一个命令全解决不需要定期清理也不用担心堆成垃圾数据。而如果项目里还没有Redis开发阶段用内存Map也能跑但要注意进程崩溃缓存全丢多实例部署时验证码会互相不认账。生产环境一定要上Redis。验证码的key命名也需要统一规划我习惯用sms:code:{phone}方便定位和排查。const Redis require(ioredis); const redis new Redis({ host: localhost, port: 6379 }); // 生成6位数字验证码 const code String(Math.floor(100000 Math.random() * 900000)); // 存Redis5分钟过期 await redis.set(sms:code:${phone}, code, EX, 300);这里可能有人问4位验证码够不够现在主流做法已经放弃4位了。原因很简单4位只有10000种组合暴力穷举在无限制条件下非常轻松6位是100万种组合配合过期时间和失败次数限制在线暴力破解基本不现实。3.2 发送验证码接口格式校验、限流、兜底发送接口是整套系统里最容易被人薅羊毛的地方。我这个实现里做了两层保护第一层是手机号格式的正则校验第二层是一个独立的一分钟限流key。const express require(express); const jwt require(jsonwebtoken); const app express(); app.use(express.json()); const SECRET process.env.JWT_SECRET || dev_secret_key; app.post(/api/v1/sms/send, async (req, res) { const { phone } req.body; if (!/^1[3-9]\d{9}$/.test(phone)) { return res.json({ code: 400, msg: 手机号格式不正确 }); } // 同一手机号60秒内无法重复发送 const limitKey sms:limit:${phone}; const isLimited await redis.get(limitKey); if (isLimited) { return res.json({ code: 429, msg: 发送太频繁请稍后再试 }); } const code String(Math.floor(100000 Math.random() * 900000)); await redis.set(sms:code:${phone}, code, EX, 300); await redis.set(limitKey, 1, EX, 60); try { await sendSms(phone, code); res.json({ code: 200, msg: 验证码已发送 }); } catch (e) { // 发送失败要清掉验证码和限流key否则用户一分钟内无法重试也拿不到正确验证码 await redis.del(sms:code:${phone}); await redis.del(limitKey); res.json({ code: 500, msg: 发送失败请稍后重试 }); } });限流key和验证码key分开存是我在线上踩过坑之后改的。最开始我用同一个key先写验证码再设置过期结果验证码过期后限流也被带走了用户等300秒重发一次体验极差。分开以后互不影响想调哪个参数调哪个参数。3.3 验证码校验登录接口验证、查户、发token现在写最核心的登录接口。最值得注意的细节是校验通过后立即删除验证码以及用户不存在时自动创建。app.post(/api/v1/auth/login, async (req, res) { const { phone, code } req.body; if (!phone || !code) { return res.json({ code: 400, msg: 参数不完整 }); } const storedCode await redis.get(sms:code:${phone}); if (!storedCode) { return res.json({ code: 400, msg: 验证码已过期请重新获取 }); } if (storedCode ! String(code)) { return res.json({ code: 400, msg: 验证码错误 }); } // 验证码校验通过立即删除防止重放 await redis.del(sms:code:${phone}); // 查用户不存在则自动注册 let user await db.users.findOne({ where: { phone } }); if (!user) { user await db.users.create({ phone, nickname: 用户${phone.slice(-4)}, created_at: new Date() }); } // 签发JWT const token jwt.sign( { userId: user.id, phone }, SECRET, { expiresIn: 30d } ); res.json({ code: 200, msg: 登录成功, data: { token, user: { id: user.id, phone: user.phone, nickname: user.nickname } } }); });很多文章会告诉你把注册和登录合并成一个接口但没有告诉你这么做有一个并发隐患如果同一个新手机号在极短时间里并发来了两个请求两个请求都查不到用户然后同时执行插入就会出现重复数据。解决方案有两种在用户表的phone字段上建唯一索引插入时捕获唯一键冲突冲突则再查一次返回已存在的用户用数据库的INSERT ... ON DUPLICATE KEY UPDATE这类原子操作去做创建我这里代码写的先查后插是为了让逻辑好读生产环境记得用唯一索引把数据兜住。3.4 用户表设计字段宁少勿多但要留扩展位注册登录功能其实只需要一张极简用户表。但字段设计要有前瞻性有些字段早晚会用到我建议一开始就加上。CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE COMMENT 手机号唯一索引, nickname VARCHAR(64) DEFAULT COMMENT 昵称默认生成的用户尾号, avatar VARCHAR(512) DEFAULT COMMENT 头像URL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;phone字段上必须建唯一索引这是防重复的最后一道防线。nickname之所以给默认值是因为很多产品允许注册后不完善资料先给个默认昵称让用户能直接进入主页面。后面你要是加头像上传、个人信息编辑这个表加字段也不别扭。3.5 统一返回结构别小看这个约定接口返回结构如果不统一前后端联调时你会被气死。接口A返回{code: 200, data: {}}接口B返回{success: true, result: {}}前端处理逻辑要写两套维护成本翻倍。我这套方案里统一了所有接口的返回格式// 成功 { code: 200, msg: 登录成功, data: { token, user } } // 业务失败 { code: 400, msg: 验证码错误 } // 频控 { code: 429, msg: 发送太频繁请稍后再试 } // 系统异常 { code: 500, msg: 发送失败请稍后重试 }我坚持用HTTP 200 业务code的方式因为在实际业务里验证码错误、参数错误这类情况本质上不是HTTP协议层的错误而是业务逻辑层的错误。把错误放到body里让前端统一按code处理逻辑简洁得多。当然这只是我的习惯用HTTP状态码映射业务错误也完全可以关键是团队内部要统一。4. 前端闭环从输入框到登录态的每一步4.1 页面结构三个元素撑起一个登录页我见过很多前端页面为登录写了两三百行代码但最核心的其实三个元素手机号输入框、验证码输入框、获取验证码按钮。这一版用原生HTML和JavaScript搭任何框架迁移都很容易。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title手机号登录/title style .login-box { max-width: 360px; margin: 80px auto; padding: 32px; border: 1px solid #eee; border-radius: 12px; } input { width: 100%; padding: 12px; margin: 8px 0; box-sizing: border-box; border: 1px solid #ddd; border-radius: 8px; } button { width: 100%; padding: 12px; margin-top: 8px; border: none; border-radius: 8px; background: #1677ff; color: #fff; cursor: pointer; } .sms-row { display: flex; gap: 8px; align-items: center; } .sms-row input { flex: 1; margin-bottom: 0; } .sms-row button { width: 140px; margin: 0; flex-shrink: 0; } /style /head body div classlogin-box h2手机号快捷登录/h2 div classsms-row input idphone typetel maxlength11 placeholder请输入手机号 / button idsendBtn typebutton获取验证码/button /div input idcode typetext maxlength6 placeholder请输入6位验证码 / button idloginBtn typebutton登录 / 注册/button /div script src./login.js/script /body /html注意一个小细节maxlength直接限制用户只能输入11位手机号和6位验证码把低级的格式错误拦截在输入层。这样即使用户乱输入也不会发起无意义的网络请求。4.2 60秒倒计时防的不是黑客是手滑倒计时这个交互几乎成了验证码登录的标配它的核心目的是让用户不要无意识重复点击。短信服务商本身也会对同号码的发送频率做控制前端如果不禁用户连点五次后面两次大概率被服务商拦截到时候用户只会觉得你的系统有问题。const phoneInput document.getElementById(phone); const sendBtn document.getElementById(sendBtn); const codeInput document.getElementById(code); const loginBtn document.getElementById(loginBtn); let timer null; function startCountdown(seconds) { sendBtn.disabled true; let remaining seconds; sendBtn.textContent ${remaining}s 后重发; timer setInterval(() { remaining--; if (remaining 0) { clearInterval(timer); sendBtn.disabled false; sendBtn.textContent 获取验证码; } else { sendBtn.textContent ${remaining}s 后重发; } }, 1000); } sendBtn.addEventListener(click, async () { const phone phoneInput.value.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { alert(请输入正确的手机号); return; } const resp await fetch(/api/v1/sms/send, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ phone }) }); const data await resp.json(); if (data.code 200) { startCountdown(60); } else { alert(data.msg || 发送失败); } });倒计时只是在按钮上做层防护真正的频率控制靠后端。你要是只在前端做了限制那别人绕过前端直接调接口照样能把你的短信费用刷爆。后来我接的项目都要求后端独立限流前端倒计时纯粹是承担用户体验层面的活。4.3 登录请求与token存储localStorage还是cookie登录成功后token的存放位置是个经典问题。我平时根据项目形态来选存储位置优点缺点适用场景localStorage简单、前端灵活取用有XSS风险任何脚本都能读到演示项目、内部系统、对安全要求不极端的产品httpOnly CookieJS读不到抗XSS需要处理CSRF跨域时Cookie设置麻烦银行、金融、高安全性要求的平台内存变量刷新即失效天然防XSS刷新页面就丢登录态配合JWT短期有效的场景对于常规业务我推荐localStorage方案——不是因为它最安全而是因为一版一的API项目用httpOnly Cookie的成本太高前端要维护CSRF token跨域还要处理CORS的credentials配置。等你的用户量和安全要求上来了再切换也不迟。loginBtn.addEventListener(click, async () { const phone phoneInput.value.trim(); const code codeInput.value.trim(); if (!code) { alert(请输入验证码); return; } const resp await fetch(/api/v1/auth/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ phone, code }) }); const data await resp.json(); if (data.code 200) { localStorage.setItem(token, data.data.token); localStorage.setItem(userInfo, JSON.stringify(data.data.user)); window.location.href /index.html; } else { alert(data.msg || 登录失败); } });4.4 登录态带上请求统一封装一个request函数登录成功后不能每次手动从localStorage取token塞进header那太原始了。我建议封装一个request方法所有业务请求统一走它。async function request(url, options {}) { const token localStorage.getItem(token); const headers { Content-Type: application/json }; if (token) headers.Authorization Bearer ${token}; const resp await fetch(url, { ...options, headers }); const data await resp.json(); // 401表示token过期或无效跳回登录页 if (data.code 401) { localStorage.clear(); window.location.href /login.html; return; } return data; }这里的data.code 401判断是配合前面统一返回结构的。后端签发token时设置了过期时间token过期后你解析出来的字段会断言失败或者过期验证失败这时就返回401前端清空登录态并跳回登录页这是最省维护成本的登录过期处理方案。5. 三分钟之外的必修课安全加固与线上避坑5.1 验证码被刷的典型攻击路径你得心里有数如果你准备上线一套验证码登录最担心的应该是短信轰炸——攻击者通过你的接口给任意手机号批量发验证码。这会带来两个直接后果短信费用被刷爆以及你的服务商因为投诉率上升把你拉黑。典型攻击手段有这么几种我结合真实遇到过的情况梳理一下直接循环调接口不带任何限制地请求/sms/send用不同手机号刷绕过前端倒计时攻击者根本不走你的页面直接用脚本发HTTP请求多IP轮换限制单一IP后攻击者用代理池绕过快频限制对付这些单一策略都不够我采用的是组合拳单手机号维度限流同一个手机号60秒内只能发一次每天最多发5次这个次数可以按业务调整单IP维度限流同一个IP地址每小时最多发起N次发送请求人机验证在发送验证码之前接入图形验证码或滑块防止脚本自动化调用。这是最有效的方式没有之一监控告警短信发送成功率、发送量突然飙升都要有告警我见过很多团队只做了手机号限流没做IP限流结果被攻击者用脚本换不同的手机号轰炸照样被打爆。公网环境对恶意的防御必须是多层的。5.2 验证码校验里被忽略的细节验证码比对不是简单的if (userCode storedCode)就完事里面有几个容易被翻车的点。第一个是类型比较。存储的验证码是字符串用户提交过来的可能是数字类型直接用去比较可能意外不相等。我习惯在存储时强制转字符串在比较时也转字符串避免类型不同导致的误判。第二个是失败次数限制。如果不限制攻击者就可以对着接口不断提交不同的6位验证码穷举。100万种组合配合每秒几十次的请求可能也就几小时的事。所以我会给验证码校验增加错误次数锁定同一手机号连续错误5次当前验证码立即作废用户必须重新获取。第三个是定时安全比较。理论上比较两个字符串时如果逐个字符比较会存在微小的时序差异理论上可以通过测量响应时间来推断验证码。实际应用中验证码这种场景被利用的可能性极低但如果你要过等保建议用Node.js的crypto.timingSafeEqual去做恒定时间比较。5.3 token的生命周期没你想的那么简单JWT签发完了不是一劳永逸后面要处理的问题还挺多一是token过期时间的选择。很多人图省事直接签发30天甚至365天代价是token泄露后的风险窗口极大。我常用的方案是access token 7天过期refresh token 30天过期。access token负责日常请求refresh token负责在access token失效后换取新的access token。当然了如果你只是做个内部系统单token30天也不是不能用安全等级是和成本挂钩的。二是登出问题。JWT是无状态的后端存不住它的状态你没法让一个已经签发的token立即失效除非引入黑名单机制。前端登出只需要把localStorage里的token删掉就行但这条路能做的也只是让这个客户端不再携带token已经拿走token的客户端依然有效。安全要求高的场景下会做Redis黑名单把要作废的token的jtiJWT ID存进去每次请求先查黑名单。三是secret的管理。JWT的签名密钥如果硬编码在代码里一旦仓库泄露攻击者就能自己造任意身份的token。正确的做法是用环境变量注入生产环境的密钥用专用的密钥管理服务比如云厂商的KMS或者最简单的在部署平台上配置环境变量。5.4 联调与上线前容易被忽略的坑我把自己踩过的坑和帮别人排查过的坑汇总一下每一条都有真实案例真机收不到短信测试时用模拟器或者测试机有时候运营商通道在部分网络环境下会拦截验证码短信。建议上线前找几台不同机型的真机配合不同的运营商移动、联通、电信各测一次。默认短信签名和模板没换开发阶段用的可能是控制台里的测试签名比如阿里云短信测试模板也可能是测试专用。上线前忘了换成正式签名结果用户收到一条写着测试字样的短信谁还敢用你的产品。并发下重复注册我之前负责过一个项目用户在同一瞬间点了两次登录按钮两个事务同时查出用户不存在然后双双插入成功导致出现两个同手机号的账号。加唯一索引然后在代码里捕获唯一键冲突异常把逻辑改成插入冲突就返回已存在的用户完美解决。手机号正则写死了中国区如果产品以后要出海1[3-9]\d{9}这个规则就不适用了。最好在架构里预留一个规则配置的位置或者业务早期就明确是否支持国际手机号。短信服务商的批次限制部分平台对同内容、同号码的短信发送有一天多少条的硬限制我遇到过连续大批量测试时突然被服务商熔断导致后续验证码全都发不出去。提前和服务商确认配额或者把发送量监控加到告警里。5.5 我最后的个人建议把开发节奏排成这样文章标题说三分钟实际上我更愿意把它理解成三步走第一步开发阶段不要接真短信通道用一个本地mock服务把所有流程跑通验证码固定成123456把精力集中在接口逻辑和前端交互上。这样不用等签名审核也不会浪费短信费效率拉满。第二步签名和模板审核下来之后把mock切换成真实短信服务重点回归一遍发送和校验流程确认验证码真能到手真能登录。第三步上线前给自己留一个安全检查清单限流有没有做验证码过期时间和失败次数限制有没有配token密钥是不是环境变量手机号正则校验是否覆盖前端倒计时有没有和后端限流配合。过完清单再进发布流程后面能省下无数救火的时间。我在这个过程里最深的体会是手机号验证码登录功能看着不大但它横跨了短信支付、后端存储、安全设计、用户体验四条线任何一个环节的单点疏忽都会在线上变成真金白银的损失或者一堆投诉工单。希望这份实操记录能让你少走一段我走过的弯路顺利把你自己的三分钟真正落地。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进