多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手机号验证码一键注册登录全流程:Redis缓存+JWT鉴权+宝塔部署

手机号验证码一键注册登录全流程:Redis缓存+JWT鉴权+宝塔部署 做过移动端应用或者网站的兄弟应该都有体会“手机号一键注册登录”这东西基本是现在抖音、快手这类流量型产品绕不开的标配。用户输入一个手机号收到验证码填进去就直接进系统了不用设置密码、不用填用户名体验很顺滑。但真把这个流程从零到一落地的时候坑比想象中多验证码生成规则、存储方案、过期策略、防刷限流、JWT鉴权、前端登录态保持甚至后面的宝塔部署、短信服务商对接每一步都有讲究。这篇博文我就结合自己做过的实际项目把一套完整的手机号一键注册登录流程拆开讲清楚从设计思路到后端接口、前端联动、生产部署再到常见问题排查一次性讲透。这个流程本身并没有多高深但它串起来的环节特别多适合正在做SPA项目、想搞清楚验证码和JWT整套链路的朋友。看完之后你不仅能自己写出来还能知道为什么这么写出了问题往哪儿排查。1. 整体设计思路与方案选型1.1 为什么手机号验证码成了标配方案很多人第一次做注册登录下意识就是“用户名密码”那一套。但放在今天的产品环境里这套思路其实有很多麻烦用户要记账号、记密码、担心密码泄露注册表单填半天。更关键的是普通产品自己维护一套密码体系安全成本很高密码存储、加密、找回密码的流程都得做。手机号验证码方案解决的核心问题就是把“身份登记”和“身份验证”合一了。手机号本身就是用户的唯一标识验证码则是“你拥有这个手机号”的证明。只要短信能收到就默认你是手机号的主人免去了记忆负担。抖音那种“一键注册登录”体验本质上就是把注册和登录合并成一个动作用户感知不到“注册”这一步。从产品体验的角度讲验证码流程还有一个隐形优势它天然完成了用户真实性的初步筛选。做营销、做内容社区最烦的就是批量注册小号手机号验证码至少把批量成本抬高了一截。1.2 注册与登录合并的产品设计我见过很多团队把注册和登录做成两个入口、两套接口其实没必要。手机号验证码模式下注册和登录可以共用一个接口用户提交手机号验证码后端先校验验证码是否正确然后去用户表里查这个手机号存不存在。如果不存在就自动创建一个新用户如果存在就直接视为登录。一次验证两条路都走通了。这么设计有几个实实在在的好处第一用户永远不会遇到“你还没有注册请先注册”这种让人想摔手机的错误第二前端代码不用维护两套逻辑第三数据库层面不需要额外区分“注册来源”还是“登录来源”字段顶多保留一个日志表记录操作类型。当然如果产品有强制完善资料的需求比如昵称、头像可以在首次登录后引导用户补全而不是在注册流程里强制填写。抖音也是这么做的先让你进来再慢慢引导你完善资料。1.3 技术栈选型与整体架构以我这次的项目为例前端用的是Vue3搭建的SPA单页应用后端用PHP兼容宝塔环境的PHP-FPMRedis做验证码缓存JWT做登录态。数据库用MySQL短信服务商用的阿里云短信。选这套组合的原因很简单部署简单、成本低、适合中小项目。宝塔面板对PHP的支持最成熟Nginx配PHP-FPM基本是点几下鼠标的事Redis做验证码存储比数据库表好使太多天然支持过期JWT则避免了服务端维护Session的麻烦接口做无状态认证前端拿到Token之后每次请求带上就行。整套流程的数据流大致是这样用户输入手机号发起请求后端生成验证码并写入Redis同时调用短信服务商接口下发短信用户输入验证码提交后后端从Redis取出来比对一致就查/建用户然后签发JWT返回给前端前端保存Token并在后续请求的Header里携带完成整个登录闭环。2. 验证码模块的核心实现2.1 验证码生成规则与参数选择验证码生成这事儿表面看很简单不就是随机几个数字嘛但参数选型直接影响安全性和可用性。首先是位数。绝大多数场景用6位数字因为6位数字有100万种组合暴力猜中的概率足够低同时用户的记忆负担还能接受。4位验证码虽然更好输入但只有1万种组合在网络请求这种可以被高频重试的场景下不够安全所以现在主流产品基本都是6位。其次是随机方式。很多新手会直接写rand(100000, 999999)这在PHP老版本里是有隐患的伪随机序列可预测性比较强。建议用random_int()它是基于操作系统的随机源生成的不可预测性更强生成验证码这种安全敏感场景一定要用这类函数。function generateCode(): int { return random_int(100000, 999999); }这里还有个细节验证码本身不应该做任何编码或加密存储因为它要被取出后和用户输入做明文比对存密文反而给自己找麻烦。真正的安全防线放在校验次数限制和有效期上而不是验证码本身的加密程度。2.2 Redis存储与过期策略验证码存哪里很多人第一反应是存数据库但我强烈建议用Redis。原因是验证码的生命周期很短通常只有几分钟这种“临时数据”用数据库存既要建表、又要写清理任务纯属给自己加戏。Redis自带过期时间写入时设好TTL到点自动删除简单干净。Key的设计我习惯这样sms:code:{手机号}值直接存6位数字字符串。TTL设为5分钟也就是300秒。同时我会用一个辅助Key记录发送时间sms:send_limit:{手机号}TTL设为60秒用来做发送频率限制。Redis还有一个好处校验时可以用GET获取也可以用Lua脚本做原子性的“取出并删除”。我建议校验成功后立刻删除验证码防止同一个验证码被多次使用。Redis的DEL命令本身就是原子的不用担心并发问题。存储结构和过期策略可以参考这张表用途Key示例值示例TTL验证码sms:code:13800138000837291300秒发送频率限制sms:send:13800138000160秒每日发送计数sms:daily:13800138000:20250601386400秒IP限流sms:ip:203.0.113.1083600秒2.3 防刷限流机制的完整设计验证码接口最容易被打的就是“短信轰炸”——攻击者用任意手机号反复触发短信下发既骚扰了用户又烧服务商的钱。所以防刷是验证码模块的重中之重。我在项目里做了四层防护缺一不可第一层单手机号发送间隔。同一个手机号60秒内只能请求一次通过sms:send:{手机号}这个Redis Key来控制存在就拒绝。第二层单手机号每日上限。同一个手机号一天最多发5条防止那个手机号的用户被恶意轰炸一天。这个用带日期的Key计数比如sms:daily:13800138000:20250601每次发送就自增。第三层IP维度限制。同一个IP一小时最多发10次防止攻击者用脚本换手机号刷。注意如果整个公司出口IP相同可能会导致用户集体受限所以IP限制的门槛要放宽一些。第四层前端倒计时只是体验优化不是安全手段。前端按钮置灰60秒很容易被绕过真正的限制必须在后端做前端倒计时只是防止正常用户手滑重复点击。如果项目流量大、防刷要求高还可以引入滑块验证码或者图形验证码在请求发送短信前先验证“你是人类”。这个成本比较高一般项目可以先不做等真被盯上了再加。2.4 短信服务商对接要点短信下发本身是调用第三方服务商的能力。国内主流就是阿里云、腾讯云、华为云这几家价格差不多接入方式也大同小异。以阿里云短信为例核心步骤就三步创建签名、创建模板、调用API。签名和模板都要在服务商后台审核审核需要提供业务说明一般半天到一天就能过。模板里会有一个${code}的变量占位符发送时传入验证码内容。模板内容类似“您的验证码为 ${code}5分钟内有效请勿泄露。”调用API时必传参数包括手机号、签名、模板码、模板参数。发送频率和内容合规都由服务商做基础管控但最终防刷责任还在我们自己的后端。还有一点要特别注意短信服务商是花钱的一条短信大概几分钱看起来不贵但被刷一次可能瞬间烧掉几百上千块。所以防刷限流不是可选项是必须项。3. 注册登录流程的完整串联3.1 前端SPA交互设计前端交互是整个流程的第一体验做得好不好直接影响用户对这个产品的第一印象。我做的流程是这样页面分成三步其实都在一个页面里第一步输入手机号第二步输入验证码第三步自动登录进入首页。手机号输入框要加格式校验国内手机号用^1[3-9]\d{9}$这个正则11位数字第一位是1第二位是3到9。不符合就直接提示不发起请求。点击“获取验证码”按钮后按钮进入倒计时状态每秒减一显示“重新获取(59s)”60秒后恢复可点击。这个倒计时要用前端定时器实现同时在后端也有频率限制兜底两边配合才不会出乱子。验证码输入框通常设计成6个独立的输入格子用户每输入一个数字自动跳到下一格数字填满后自动触发登录逻辑。这个小交互在移动端体验提升非常明显抖音就是这么做的。3.2 发送验证码接口实现后端发送验证码接口一般设计成两个接口、两个动作先“请求发送验证码”再“提交验证码完成登录”。发送验证码的接口路径我定义为/api/sms/send参数只需要phone。逻辑顺序是校验手机号格式检查Redis中sms:send:{phone}是否存在存在则提示“发送过于频繁”检查sms:daily:{phone}:{date}是否超过上限生成6位验证码写入Redissms:code:{phone}TTL 300秒写入sms:send:{phone}TTL 60秒自增每日发送计数调用短信服务商API发送短信public function sendSms(Request $request) { $phone $request-input(phone); if (!preg_match(/^1[3-9]\d{9}$/, $phone)) { return json([code 400, msg 手机号格式不正确]); } $redis Redis::connection()-client(); // 发送间隔限制 if ($redis-exists(sms:send:{$phone})) { return json([code 429, msg 发送过于频繁请稍后再试]); } // 每日次数限制 $today date(Ymd); $dailyKey sms:daily:{$phone}:{$today}; if ($redis-incr($dailyKey) 5) { return json([code 429, msg 今日发送次数已达上限]); } $redis-expire($dailyKey, 86400); // 生成验证码并存储 $code random_int(100000, 999999); $redis-setex(sms:code:{$phone}, 300, $code); $redis-setex(sms:send:{$phone}, 60, 1); // 调用短信服务商 $result SmsService::send($phone, $code); return json([code 0, msg 发送成功]); }这段代码把上面的防刷逻辑都落进去了。如果短信服务商返回失败一定要记得删除已经写入的Redis内容否则用户明明没收到短信系统却认为已经发送了60秒内无法重发体验会很差。3.3 验证码校验与登录接口实现校验接口/api/login接收两个参数phone和code。逻辑如下校验参数非空从Redis取sms:code:{phone}不存在说明过期或未发送返回“验证码已过期”比对验证码不一致返回“验证码错误”校验一致后立即删除Redis中的验证码查询用户表手机号不存在则创建新用户签发JWT Token返回Token和用户信息关于验证码比对有人会忽略大小写、空格等问题数字验证码不存在这个问题但最好还是严格用比对。还有一个常见陷阱用户可能在多个设备上收到验证码A设备用了一次B设备再用就失效了所以校验成功后必须删除。public function login(Request $request) { $phone $request-input(phone); $code $request-input(code); $redis Redis::connection()-client(); $cachedCode $redis-get(sms:code:{$phone}); if (!$cachedCode) { return json([code 400, msg 验证码已过期请重新获取]); } // 限制错误次数防止暴力尝试 $errorKey sms:error:{$phone}; if ($redis-get($errorKey) 5) { return json([code 429, msg 错误次数过多请重新获取验证码]); } if (!hash_equals($cachedCode, $code)) { $redis-incr($errorKey); $redis-expire($errorKey, 300); return json([code 400, msg 验证码错误]); } // 校验成功清理验证码和错误计数 $redis-del(sms:code:{$phone}, $errorKey); // 查或建用户 $user User::firstOrCreate([phone $phone]); // 签发JWT $token JwtHelper::generateToken($user-id, $phone); return json([code 0, data [ token $token, user $user ]]); }这里我用hash_equals做比对它能防止时序攻击。虽然验证码场景下攻击成本已经比较高但好习惯该有还是得有。3.4 JWT签发与前端Token管理JWT是当前SPA项目最主流的登录态方案。它是一段包含三部分的字符串Header、Payload、Signature分别用点号分隔。Header声明算法Payload存放用户相关信息Signature用服务端密钥对前两部分签名防止内容被篡改。签发Token时我习惯在Payload里放三个字段user_id、phone、exp。过期时间一般设7天兼顾体验和安全性。密钥必须足够长至少32位随机字符串存放在服务端配置文件中绝不能硬编码在前端代码里。public static function generateToken(int $userId, string $phone): string { $header base64_encode(json_encode([alg HS256, typ JWT])); $payload base64_encode(json_encode([ uid $userId, phone $phone, iat time(), exp time() 7 * 86400 ])); $signature hash_hmac(sha256, $header.$payload, self::SECRET_KEY); return $header.$payload.$signature; }前端拿到Token之后要保存到本地存储。我推荐保存在localStorage或者 Pinia/Vuex 这样的状态管理库中持久化到本地。放在内存里刷新页面就丢了每次刷新都要重新登录体验不好。请求拦截器里统一加上 Authorization Headeraxios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });路由守卫负责全局登录态校验。前端路由配置里需要登录的页面统一走一个守卫如果本地没有Token直接重定向到登录页。这不是安全手段纯粹是用户体验层面的引导真正校验Token还是要靠后端接口的鉴权中间件。如果项目要求更严可以再加一层刷新Token机制给用户的Token设置较短的有效期比如2小时另外签发一个有效期较长的Refresh Token前端发现Access Token过期后用Refresh Token自动换取新Access Token用户全程无感知。4. 宝塔部署与运行环境配置4.1 宝塔面板环境准备做完开发和本地测试上线部署我选了宝塔面板原因前面说过省事稳定。先在宝塔软件商店里装好Nginx、PHP 8.1、Redis、MySQL这几个基础组件。PHP装完后需要确认启用了必要的扩展redis扩展、pdo_mysql、openssl、fileinfo。在宝塔的PHP设置里找到“安装扩展”选项卡直接搜redis一键安装。如果没有redis扩展后面代码里所有Redis操作都会报“Class not found”错误。Redis服务本身也需要设置一个访问密码默认端口6379不能裸奔在公网上。在宝塔的Redis配置里设置requirepass字段然后在业务代码的Redis连接配置里填上对应密码防止被扫到之后恶意操作。4.2 Nginx伪静态、HTTPS与跨域配置Nginx配置是部署环节最容易踩坑的地方。宝塔新建站点时如果用的ThinkPHP、Laravel这类框架记得在“伪静态”里选对应的规则否则所有请求都会404。核心就是把请求重写到入口文件index.php。然后是一定要上HTTPS。现在任何正经项目都别考虑裸HTTP了倒不是纯安全洁癖而是很多浏览器功能、短信服务商回调、微信内嵌浏览器都会对HTTP页面有限制。宝塔申请Let‘s Encrypt免费证书很方便或者用云服务商提供的免费证书配好之后强制跳转HTTPS。跨域问题在前后端分离项目里几乎必踩。如果是同一个域名部署比如api.example.com和www.example.com需要在后端处理好CORS更简单的方案是用Nginx反代把/api路径代理到后端PHP服务这样前端和后端在浏览器看来是同一个域名从根上绕开跨域。location /api/ { proxy_pass http://127.0.0.1:9000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }4.3 生产环境参数调整本地测试时验证码TTL可以设短一点方便调试但生产环境务必把所有参数收敛成配置项验证码有效期、发送间隔、每日上限、IP限额、JWT过期时间。不要硬编码在业务代码里否则每次调参数都要改代码重新部署。建议放到.env环境变量文件中SMS_CODE_TTL300 SMS_SEND_INTERVAL60 SMS_DAILY_LIMIT5 JWT_SECRET你的超长随机密钥 JWT_EXPIRE_DAYS7 REDIS_HOST127.0.0.1 REDIS_PASSWORD你的redis密码还有一点容易被忽略服务器时区。PHP的date()函数默认取系统时区如果服务器时间跟北京时间差了好几个小时每日计数用的date(Ymd)就会对不上用户晚上23:50用完当日次数服务器却还是“昨天”凌晨突然又能发了。生产环境一定要在PHP配置里设置date.timezone Asia/Shanghai。5. 常见问题排查与避坑实录5.1 用户收不到验证码排查顺序是什么收不到验证码是反馈率最高的问题没有之一。遇到这种情况我从上到下的排查顺序是第一步看后端日志有没有发送记录。如果根本没有调短信接口那是我们自己的限流或者参数校验拦截了看返回的错误提示就行。第二步看短信服务商控制台的发送记录。服务商一般都有发送状态查询状态是“送达”还是“失败”失败原因是什么。常见的是“签名未通过”“模板不正确”“内容包含敏感词”等。第三步确认手机号本身没问题。虚拟运营商号段170/171开头和部分物联网卡对短信接收有天然限制有些统一验证码平台都发不进去这种属于通路问题我们控制不了只能提示用户换号。第四步检查用户是否把服务商号码拉进了黑名单。这个很常见比如用户之前退订过营销短信或者手机系统全局拦截了未知号码短信验证码会被直接吞掉。这个我们无法从代码层直接解决只能引导用户查看拦截记录。5.2 验证码提示已过期或不存在这种问题通常是三个原因一是用户真的过了5分钟才输入二是用户在多个页面或设备上重复获取了验证码每次都生成新的Redis里旧的就覆盖了三是校验成功后立刻删除的机制导致同一条验证码只能在第一个提交的设备上成功另一个设备再提交就提示过期。这里有一个设计取舍为了代码简单我选择的是“成功后即删”这样最安全。但如果你确认业务场景就是允许同一验证码在多个设备同时登录那就不要用DEL改成标记已使用状态。不过我的建议是保持成功即删用户多看多等几秒重新获取一次验证码不是什么大不了的事。5.3 频繁触发限流但用户确实没收到验证码这个问题是前面两者叠加出来的用户第一次没收到60秒后又点了一次被前端倒计时拦住了等倒计时结束用户再点被后端60秒间隔拦住了。用户本来就烦还被连拒两次体验非常差。解决办法是把短信接口设计的更宽容一点发送失败的情况下允许立即重试也就是调用短信服务商返回失败时主动删除sms:send:{phone}这个限流Key。另外可以把倒计时改成前端60秒、后端45秒后端比前端稍微短一点这样极端情况下用户晚点提交也不会被后端拦。还有个小技巧如果短信服务商提供“发送状态回调”可以在回调里记录成功或失败。用户反馈收不到时技术支持可以直接查回调状态不用猜。5.4 验证码安全加固的几个细节最后说说安全。手机号验证码这个体系的弱点很明显验证码只有6位数字而且输入框允许反复尝试。虽然我们的防刷做了错误次数限制但还有一些容易忽略的细节第一个是图形验证码。如果哪天你发现某个手机号在半夜被疯狂请求获取短信说明IP限流已经被绕过了这时候最有效的方案就是在发送短信接口前加滑块验证码。这不是每个项目都要上但一旦出现被刷迹象优先加这个。第二个是日志审计。每次发送短信、校验成功、校验失败都要打日志包含手机号、IP、User-Agent、时间。平时看似没用出安全问题的时候这些日志能帮你还原整个攻击路径。第三个是敏感信息保护。手机号属于敏感个人信息接口返回日志和前端页面展示时都要做脱敏比如138****8000。后端日志里也不要存完整的验证码明文存哈希值方便排查看不出原文。我把上面这些问题整理成速查表排查的时候照着走就行现象可能原因排查方向用户收不到验证码手机号拦截、服务商模板问题、接口被限流查看后端日志与服务商发送记录验证码一直显示错误Redis中验证码被覆盖、多设备同时使用检查Redis Key校验成功后是否被删提示发送过于频繁60秒间隔未过、IP受限查看Redis剩余TTL确认限流Key短信费用异常暴涨被恶意刷接口检查IP维度日志启用图形验证码登录后接口401Token过期、本地Token损坏检查JWT过期时间确认前端存储逻辑5.5 上线之前的最后检查清单我在项目上线前每次都会过一遍这份清单省了很多返工首先确认验证码最短6位、最长不要超过6位输入框走完自动提交不要给用户按回车提交的机会。其次确认所有接口都走了同一个登录鉴权中间件不要出现某个接口忘了加鉴权变成裸奔。再次确认短信服务商的签名和模板都已经是正式状态测试模板在生产环境会直接导致发送失败。然后确认线上数据库用户表的手机号字段加了唯一索引否则并发请求下同一个手机号可能创建出多个账号。最后跑一遍买单全流程从发送验证码到登录成功、再到刷新页面保持登录态全链路走通才算完。结尾做完这个完整的手机号一键注册登录流程我最大的感受是这功能看起来是个小闭环但它其实把验证码生成、缓存存储、限流防刷、短信对接、用户系统、JWT鉴权、前端状态管理、服务器部署全部串了一遍。任何一个环节出问题用户端的表现就是“收不到码”“登不进去”非常致命。如果你是自己练手或者做小项目建议先按这个方案完整跑通一遍再把中间的安全细节慢慢补上。我在实际项目里最常跟团队强调的一句话是验证码流程不是写完就完事了要把它当成一个需要持续运维的基础设施来看日志要留、监控要上、限流参数要看情况调。希望这篇实战记录能帮你少踩几个坑。
返回列表