ARTICLE DETAIL

资讯详情

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

基于Redis的邮箱验证码存储与校验方案:Spring Boot与QQ邮箱SMTP完整实现

基于Redis的邮箱验证码存储与校验方案:Spring Boot与QQ邮箱SMTP完整实现 做项目做到注册、登录、找回密码这一步十有八九都会碰上邮箱验证码这个功能。我最早做的时候图省事直接把验证码扔数据库里没多久就被线上问题教育了用户收不到邮件重复点击、验证码超时没人清理、同一封邮件被反复校验……后来换成Redis存验证码整个链路清爽了很多。这里把我用QQ邮箱做模拟验证码验证的完整方案拆开讲一遍从环境配置到核心代码再到排坑思路照着做就能跑通。1. 方案设计为什么验证码必须放Redis1.1 验证码场景的真实痛点先想清楚验证码这个东西的存储诉求。一个验证码从生成到失效生命周期很短通常是3到5分钟。如果我们把它写进数据库表每次校验都要走一次SQL查询高并发场景下数据库压力不小。更要命的是那些过期数据如果不做定时清理表会越积越臃肿总觉得为这点小功能养个定时任务不太值。如果不用数据库简单写个内存Map存验证码行不行本地测试可以但一上生产就露馅。应用一重启所有登录中的验证码全部消失多实例部署时请求打到不同机器上A机器存的验证码B机器根本读不到用户明明输入对了还是报错。这就是典型的无状态服务被有状态数据坑了的案例。Redis天然就是为这种短生命周期、高频读写、跨实例共享的数据设计的。验证码存Redis本质上就是把临时状态从业务服务器里剥离出来放到统一的缓存层去管理。这不只是换个存储位置的问题而是让整个服务的扩展性变好了——你随便横向扩容验证码公共访问谁也丢不了谁。1.2 技术选型Redis为什么是最优解从数据类型上看验证码就是一个典型的String类型。一条记录存下来设置过期时间读写都只有一次不需要复杂的数据结构。有人可能会想用Redis的Hash或者List是不是也行能存是能存但没必要。String配合SETEX命令一个操作就把存值和过期时间一起搞定了原子性、效率都拉满。Redis还有一个很大的优势是自带过期机制。验证码5分钟失效你只需要在写入的时候设置expire时间一到Redis自动帮你删除完全不需要写定时任务去扫表清理。这个特性看起来不起眼但省掉的心力和维护成本实际操作过才懂。安全层面也得提一句。验证码这种敏感临时凭证过期时间越短被重放攻击的风险越小。Redis的高性能读写让短过期时间在技术上完全没有压力反正是毫秒级操作就算用户反复点发送系统也撑得住。1.3 整体流程拆解发送链路和校验链路这个功能的完整流程其实有两条链路一条是发验证码一条是验验证码。发验证码的链路是这样用户在前端点获取验证码后端收到请求后生成一个6位随机数字把它作为Value存进RedisKey就用captcha:email:用户邮箱这种格式同时设置5分钟过期。存好之后再调用邮件服务把这串数字发到用户邮箱。校验链路就反过来用户把收到的验证码填进表单后端拿用户提交的邮箱作为Key去Redis里取之前存的验证码然后和用户提交的验证码比对。如果一致校验通过同时顺手把这个Key删掉保证一个验证码只能用一次如果不一致或者Key已经过期不存在就返回校验失败。这两条链路核心都不复杂难点全在细节验证码怎么生成才安全、Redis的Key怎么设计才能区分不同业务场景、发送失败时Redis里的脏数据怎么处理、怎么防止用户高频请求轰炸后端。这些我在后面会逐个展开讲。2. 环境准备QQ邮箱SMTP和Redis的配置2.1 Spring Boot项目引入必要依赖我用的是Spring Boot为基础搭建先把最关键的两个依赖加进pom.xml。一个是操作Redis的spring-boot-starter-data-redis一个是发邮件用的spring-boot-starter-mail这俩都是Spring Boot官方封装好的starter引入之后几乎不需要额外写工具类。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId /dependency如果你用的是Maven加上之后重新加载依赖就可以了。这两个starter会自动装配RedisTemplate和JavaMailSender少写很多样板代码。2.2 QQ邮箱SMTP开通与授权码获取新手必看这一步是整个流程里最容易卡住的地方。QQ邮箱不像某些邮箱服务直接用邮箱密码就能发信它强制要求使用授权码。所谓授权码就是你在QQ邮箱里为第三方客户端单独生成的一个密码作用范围只限SMTP服务避免你的QQ密码因为第三方应用泄露。操作路径是登录QQ邮箱网页版进入设置找到账户标签往下翻能看到POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务这一栏找到SMTP服务这一项点击开启。开启的过程中会让你发一条短信验证身份确认之后会给你一串16位左右的字母和数字组合这就是授权码。注意这串码只在生成的时候完整显示一次之后再看就只能重新生成。拿到授权码之后把它当成spring.mail.password的值填进配置千万不要填你的QQ邮箱登录密码否则连接SMTP服务器时一定会报535 Authentication Failed。2.3 application.yml核心配置项整个邮件和Redis的配置我都汇总到application.yml里这样维护起来直观。先看邮件部分spring: mail: host: smtp.qq.com port: 465 username: 你的QQ邮箱qq.com password: 你的邮箱授权码 default-encoding: UTF-8 properties: mail: smtp: ssl: enable: true socketFactory: class: javax.net.ssl.SSLSocketFactoryQQ邮箱的SMTP服务器支持465端口SSL加密和587端口STARTTLS加密。我用的是465因为这个端口配合SSL的兼容性最好几乎不会因为加密协议问题连不上。如果你本地网络环境对465限制比较多可以换成587对应配置改成smtp.starttls.enable: true。Redis的配置比较简单本地开发一般就是用默认的localhost:6379。如果像我一样装了Redis Desktop Manager做可视化查看连的也是这个地址。spring: data: redis: host: localhost port: 6379 database: 0 timeout: 3000ms这里有一点要提的是timeout默认值是0表示无限等待我习惯设成3秒。如果Redis服务挂了后端请求不会无限阻塞而是快速失败并返回错误信息这对用户体验很重要。3. 核心代码实现验证码生成、存储、发送、校验3.1 验证码生成随机数怎么生成更可靠生成6位数字验证码很多人第一反应是new Random().nextInt(999999)但这样会有一个小坑生成的数字可能不足6位。虽然前面补0也不是不行但用户收到的验证码变成000123这种总归体验有点怪。我的做法是用ThreadLocalRandom生成一个从100000到999999的整数一次性保证6位。之所以用ThreadLocalRandom而不是Random是因为前者在高并发场景下性能更好线程竞争更少也避免了多线程共享Random实例带来的安全问题。private String generateCode() { return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); }有的业务会要求验证码不能出现重复数字这种花活我个人的看法是6位数字空间足够大一百万分之一的碰撞概率基本可以忽略没必要过度设计。你要是真不放心可以生成之后去Redis里做一次唯一性检查但我从实际经验看完全没必要。3.2 Redis存储Key设计和过期时间怎么定Key设计是这块的核心。我用的格式是captcha:email:用户邮箱比如captcha:email:testqq.com。冒号分隔的习惯是Redis生态里的通用规范相当于命名空间用Redis Desktop Manager看着也整齐后面想按前缀批量删除也方便。如果业务里不止一个场景需要验证码比如注册、登录、找回密码各来一发建议在Key里再加一个业务维度比如captcha:register:email:testqq.com。这样不同业务之间的验证码相互独立不会出现注册时发的验证码被拿去登录用的问题。写入Redis的时候我会用stringRedisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES)。注意这里带着过期时间一起set不是set完了再单独调expire。多一条命令就多一次网络往返中间还可能有间隙虽然实际出问题的概率极低但养成好习惯总是没错的。过期时间设多长呢5分钟是我比较推荐的值。设太短用户还没看完邮件验证码就没了容易烦躁设太长验证码长时间有效被暴力破解和重放攻击的风险会变大。5分钟在用户体验和安全之间是个比较平衡的点。3.3 邮件发送JavaMailSender实战发邮件这一步Spring Boot的JavaMailSender已经封装得很好。创建一个SimpleMailMessage设置发件人、收件人、主题、正文然后交给mailSender.send()就完事了。核心逻辑看这段Service public class MailService { Resource private JavaMailSender mailSender; Value(${spring.mail.username}) private String from; public void sendCaptcha(String to, String code) { SimpleMailMessage message new SimpleMailMessage(); message.setFrom(from); message.setTo(to); message.setSubject(【XX系统】验证码通知); message.setText(您正在使用邮箱验证服务验证码为 code 5分钟内有效请勿泄露给他人。); mailSender.send(message); } }有一点需要注意setText里存的纯文本默认按UTF-8发送中文完全没有问题。我之前犯过一个错误在正文里直接拼HTML标签但用的还是默认的纯文本发送方式结果用户收到一封显示着b验证码/b这种源码的邮件观感很差。后来发现发送HTML内容要用MimeMessageHelper设置setText(html, true)才算彻底解决。3.4 发送逻辑封装串联Redis和邮件服务光有生成、存储、发送这几个零散方法还不够核心的发送验证码逻辑要把它们穿起来同时还要考虑到发送失败时的数据清理。发邮件的动作放在最后的考虑是如果Redis写入失败没必要发邮件如果Redis写入成功了但邮件服务调不通那Redis里的验证码就变成了无效数据必须删掉不然用户下次点击发送时会以为验证码已经发出来了。我封装了一个CaptchaService完整逻辑看下面的代码Service public class CaptchaService { private static final String CAPTCHA_PREFIX captcha:email:; private static final long CAPTCHA_EXPIRE_MINUTES 5; Resource private StringRedisTemplate stringRedisTemplate; Resource private MailService mailService; public void sendCaptcha(String email) { String code generateCode(); String key CAPTCHA_PREFIX email; try { stringRedisTemplate.opsForValue().set(key, code, CAPTCHA_EXPIRE_MINUTES, TimeUnit.MINUTES); mailService.sendCaptcha(email, code); } catch (Exception e) { stringRedisTemplate.delete(key); throw new RuntimeException(验证码发送失败请稍后重试, e); } } private String generateCode() { return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); } }这段代码有两点值得注意。第一使用了StringRedisTemplate而不是RedisTemplate。因为验证码存的是纯字符串StringRedisTemplate默认的序列化器就是String方式存取之间不会出现乱码或者带莫名前缀的问题。第二try-catch里先写Redis再发邮件一旦发邮件失败就删掉刚写进去的数据保证内存里的状态和用户实际收到的状态保持一致。3.5 校验逻辑从Redis取值并一次删除校验验证码看起来就是先查再比实际上有个细节很关键校验成功之后一定要把Redis里的Key删掉让验证码变成一次性凭证。不然用户反复提交同一个验证码后端每次都放行验证码就失去意义了。public boolean verifyCaptcha(String email, String code) { String key CAPTCHA_PREFIX email; String storedCode stringRedisTemplate.opsForValue().get(key); if (storedCode ! null storedCode.equals(code)) { stringRedisTemplate.delete(key); return true; } return false; }这个逻辑是取出来后比对比对成功就删。如果取出来的值是null说明验证码已经过期或者一开始就不存在直接返回false。比对时我用的是storedCode.equals(code)而不是反过来是因为storedCode已经在非空判断之后不会出现空指针问题。从严格意义上讲这段代码存在一个极小的并发漏洞两个请求同时带着同一个正确验证码来校验可能都通过了。要彻底解决得用Redis的Lua脚本做原子操作但对于大部分项目来说验证码功能本身就不是高敏感资产这点风险可以接受。如果你想做得更严谨可以搜一下Redis Lua脚本优化验证码校验这个话题。4. 接口层串联Controller配合前端联调4.1 发送验证码接口有了Service层Controller层就比较薄了。我一般会设计两个接口一个负责发送一个负责校验。发送接口接收前端传过来的邮箱地址调用captchaService.sendCaptcha()返回成功信息。这里我在参数上做了一点约束交给Validated和Email注解做基础的格式校验避免把乱七八糟的字符串传到Service层去。RestController RequestMapping(/api/auth) public class AuthController { Resource private CaptchaService captchaService; PostMapping(/sendCode) public String sendCode(RequestParam Email String email) { captchaService.sendCaptcha(email); return 验证码发送成功; } PostMapping(/verifyCode) public String verifyCode(RequestParam Email String email, RequestParam String code) { boolean passed captchaService.verifyCaptcha(email, code); return passed ? 验证通过 : 验证码错误或已过期; } }校验接口接收邮箱和用户输入的验证码两个参数把结果封装成简单的字符串返回。实际项目中肯定是要返回统一JSON结构的这里为了突出核心逻辑先用最简单的返回方式你根据自己项目的统一响应体去替换就行。4.2 用Postman做完整联调接口写完我用Postman做了一轮完整的联调测试。先发一个POST请求到/api/auth/sendCode参数里加上email你的测试邮箱qq.com。正常情况下接口返回验证码发送成功同时你的QQ邮箱会在几秒内收到一封新邮件。这时候打开Redis Desktop Manager连上本地Redis刷新一下Key列表能看到一个captcha:email:你的测试邮箱qq.com的KeyValue就是你收到的那个6位验证码。这一步非常直观也能帮你确认Redis写入是否成功。然后发起第二个POST请求到/api/auth/verifyCode参数带上同样的邮箱和邮件里的验证码。返回验证通过再去Redis里看那个Key已经被删掉了。如果你再重复请求一次校验接口就会返回验证码错误或已过期。4.3 前端页面的交互流程参考前端这边常见的交互流程是用户填完邮箱点获取验证码按钮进入60秒倒计时同时把这个请求发到/sendCode接口用户收到邮件后填入验证码提交表单时带着邮箱、验证码一起调/verifyCode。如果校验通过就正常走注册或登录流程不通过就提示用户重新输入。倒计时的逻辑可以放在前端做也可以后端在发送验证码的响应里返回一个expireIn字段表示秒数。我更推荐后者因为前端时钟不准的问题挺常见的后端统一告诉前端多久之后才能重新发送体验更一致。5. 常见问题与排查技巧实录5.1 邮件一直发不出去报535 Authentication Failed这个问题90%的情况是授权码填错了或者把QQ邮箱的登录密码当成了SMTP密码。排查思路很直接先去QQ邮箱设置里确认SMTP服务是开启状态并拿到正确的授权码。确认之后看配置里spring.mail.password这一项把授权码重新填一次重启应用再试。如果授权码确认没问题还报错再看看端口和加密协议。QQ邮箱要求必须用加密通道465端口配SSL、587端口配STARTTLS如果加密方式不对服务端会直接拒绝连接。5.2 邮件发送成功但Redis里Key已经存在导致用户重新发送时拿不到新验证码这个坑我踩过。顺理成章的发送逻辑是先写Redis再发邮件如果邮件代码一调用就报错我们会进入catch块删除Key。但有一种情况很隐蔽邮件服务其实发出去了只不过因为网络抖动或者延时JavaMailSender这边抛了个超时异常导致Redis里的Key被误删。用户那边收到邮件了后端Redis里反而没有验证码等用户来校验的时候必然失败。我现在的处理方式是把邮件是否真正发送成功的判断做重试而不是直接删除。更稳妥的做法是先发邮件成功之后再写Redis虽然理论上有一小段时间用户已经到了验证码但后端还没写入但实际影响微乎其微而一致性大幅提升。我最终还是选择了后写Redis的顺序。5.3 怎么防止用户疯狂点击发送验证码用户的耐心是有限的但不太需要频繁点。要防验证码轰炸最简单实用的方案是在发送前检查Redis里有没有同一个邮箱的发送冷却Key。比如用户每次发送成功之后额外存一个captcha:limit:email过期时间60秒。下一次点击进来先查这个Key如果存在就直接返回请勿频繁发送不存在才继续走发送逻辑。private static final String LIMIT_PREFIX captcha:limit:; public void sendCaptchaWithLimit(String email) { String limitKey LIMIT_PREFIX email; Boolean canSend stringRedisTemplate.opsForValue().setIfAbsent(limitKey, 1, 60, TimeUnit.SECONDS); if (Boolean.FALSE.equals(canSend)) { throw new RuntimeException(发送过于频繁请60秒后重试); } // 继续发送逻辑 }这里用的是setIfAbsent命令也就是Redis里的SETNX只有Key不存在的时候才能设置成功天然适合做这种轻量级的限流。5.4 Redis出现序列化乱码如果你用的是RedisTemplate而不是StringRedisTemplate可能会发现Redis里存进去的值带了\xAC\xED\x00\x05t...这种前缀。这其实是默认的JDK序列化器在搞鬼。验证码这种纯字符串数据直接用StringRedisTemplate就能解决或者在配置RedisTemplate时把Key和Value的序列化器都改成StringRedisSerializer。5.5 本地连接不上Redis服务排查顺序建议先确认Redis进程是否在运行。Windows下我一般是用redis-server.exe redis.windows.conf启动看到Ready to accept connections字样才算启动成功。然后用redis-cli ping返回PONG说明连接正常。如果还是不通过检查一下项目配置里的spring.data.redis.port是不是被改过。最后再分享一点经验这个功能做完之后我最大的感受是——光把功能跑通不难真正拉开差距的是那些异常路径处理。验证码这个功能虽小但把发送失败清理、一次性校验、频率限制这些细节都考虑到代码质量会上一个台阶。如果你后续想扩展可以考虑把验证码从纯数字升级为图形验证码或滑动验证码存储和校验逻辑核心不变只是在生成阶段多一层校验。也可以把发送通道从QQ邮箱换成阿里云邮件推送配置方式类似但发送成功率更稳定。总之先把这个链路跑通后面换通道、加验证方式都是顺势而为的事。
返回列表