做完登录模块的人应该都有同感把“发验证码”挂到接口上只需要几十行代码真正麻烦的是验证码生命周期、重复发送的防刷、用户输错后的处理、密码找回之后旧登录态怎么作废这些边角问题。最近我用SpringbootVue给某内部系统补上了邮箱验证码登录和忘记密码功能整条链路走下来踩了不少坑今天把方案设计和排坑过程整理出来。这篇文章适合正在做类似登录模块的开发者也适合想了解邮箱验证码完整实现思路的产品和测试同学。整套方案骨架不复杂用户输入邮箱后端生成6位数字验证码存到Redis并设置5分钟过期通过SMTP把验证码发到用户邮箱。用户回填验证码后端比对通过后执行登录并签发JWT。忘记密码只是把“登录成功”替换成“更新密码”验证码的生成、存储、校验完全复用。但要把它做得稳需要注意的细节远比想象中多。1. 先理清需求邮箱验证码是怎么撑起两种登录场景的1.1 两种业务的真正共性邮箱登录和忘记密码看着是两个需求本质上都是同一件事证明“这个邮箱属于你”。从代码设计的角度看发送验证码、保存验证码、校验验证码三个动作完全一致唯一区别是验证通过后的动作登录场景签发token忘记密码场景更新密码。如果一开始就认识到这一点就不会把两份逻辑各写一遍。我见过有人把发送验证码逻辑复制了两份结果过期时间、冷却时间、邮件文案改了一个忘了一个后面维护起来非常痛苦。我在项目里的做法是把验证码当前核心服务按业务场景区分Redis里的Key。例如login:captcha:{email}用于邮箱登录reset:captcha:{email}用于忘记密码重置以后如果新增“绑定邮箱”“更换邮箱”的需求直接加一类Key即可新增成本极小。1.2 验证码生命周期与整体数据流整个流程走一遍就能看清楚关键节点在哪里。用户进入登录页输入邮箱点击发送验证码。后端先做冷却检查比如60秒内不能重复发送然后生成6位随机数字以邮箱作为Key写入Redis过期时间5分钟同时调用JavaMailSender把验证码发到对方邮箱。用户收到邮件后输入验证码后端以相同的Key去Redis读取比对通过后立刻删除Key保证验证码是一次性的随后执行登录逻辑。这个过程里容易引发争议的点有三个验证码必须一次性使用校验成功或过期后旧的验证码要立即失效。有效期不能太长5分钟是比较常规的折中值太短用户来不及切邮箱太长安全风险增大。冷却必须生效没有冷却的发送接口可以被脚本刷成邮件炸弹。举个生活化的例子验证码就像一次性门禁卡进门一次就作废而且5分钟后自动失效一分钟内还不能申请第二张。1.3 RedisKey设计与存储选型验证码该存Redis还是数据库我更偏向Redis。原因很直白验证码天然自带过期时间Redis的TTL比自己的定时任务省事得多校验后删除也只是一个删除操作。存储结构其实不复杂我整理了一份当时的设计表Redis KeyValueTTL用途login:captcha:{email}6位数字验证码300秒邮箱登录校验login:cooldown:{email}任意标记60秒同一邮箱发送冷却reset:captcha:{email}6位数字验证码300秒忘记密码校验reset:cooldown:{email}任意标记60秒同一邮箱发送冷却这里有一个特别容易踩的坑Redis的序列化方式。如果项目里用的是默认RedisTemplateKey会被JDK序列化加一串二进制前缀在命令行看Key会非常痛苦。我建议使用StringRedisTemplateKey和Value都按字符串处理干净直观。另外邮箱有时会出现大小写混用的情况。处理办法很简单进入验证码服务后第一时间把邮箱转成小写再拼接Key否则用户第一次用大写第二次用小写就会出现“验证码明明还在后端却查不到”的诡异问题。2. 后端地基邮件通道、验证码生成与防刷配置2.1 Spring Boot邮件发送的配置与授权码后端我用的是Spring Boot引入spring-boot-starter-mail后配置量其实很小。很多文章一上来就贴application.yml但少讲了一个关键前置条件绝大多数邮箱服务商不会让你直接用邮箱登录密码去做SMTP认证而是要求先在邮箱设置里开启SMTP服务然后生成一个专用授权码。这个授权码才是配到Spring配置里的password。我当时用的是某企业邮箱配置结构参考如下spring: mail: host: smtp.example.com port: 465 username: no-replyexample.com password: your-auth-code-here default-encoding: UTF-8 properties: mail.smtp.auth: true mail.smtp.ssl.enable: true mail.smtp.timeout: 5000 mail.smtp.connectiontimeout: 5000这里有三个点值得注意。第一465是SSL加密端口很多本地机房会封掉25端口所以我一直不依赖默认的25。第二connectiontimeout和timeout必须配置否则SMTP不通的时候接口可能挂几十秒才超时前端体验极差。第三username和发件人地址尽量保持一致因为不少服务商只允许用完整邮箱账号认证不一致会直接认证失败。邮件发送的Service我封装得尽量薄Service public class MailService { Autowired private JavaMailSender mailSender; public void sendCodeMail(String toMail, String code) { SimpleMailMessage message new SimpleMailMessage(); message.setFrom(no-replyexample.com); message.setTo(toMail); message.setSubject(你的验证码); message.setText(你的验证码是 code 5分钟内有效。请勿泄露给他人。); mailSender.send(message); } }不要忽略邮件文案。验证码邮件里最好不要放链接因为很多邮件服务商对链接敏感容易被判定为钓鱼邮件。纯文本的验证码反而更安全文案写清楚有效期和用途就好。2.2 验证码生成算法与一次性特性验证码生成我用的是SecureRandom不是java.util.Random。两者差异在于随机数质量和可预测性。登录功能涉及安全哪怕验证码被预测一次都可能导致账号风险。SecureRandom是加密强度的随机数对登录场景是最低要求。核心方法很简单private String generateCode() { SecureRandom random new SecureRandom(); return String.format(%06d, random.nextInt(1_000_000)); }6位纯数字从000000到999999一共100万种组合。配合5分钟过期和失败次数限制暴力破解窗口已经很小。有些系统喜欢用字母和数字混合验证码也是一个思路但会增加用户输入成本。邮箱登录场景里用户大概率在电脑前输入纯数字更快所以我保持6位数字方案。这里要特别提醒验证码只能出现在邮件正文里不能出现在接口响应里也不能在日志里打印完整验证码。开发阶段为了方便调试可以加一个debug开关但上线前必须关闭。如果确实需要在日志里定位问题最多打印后两位比如“code123456”输出成“***456”。2.3 发送接口的频率限制与失败回滚发送验证码接口是整个登录模块里最容易被打的接口。攻击脚本只要拿到一个邮箱列表就能无限调用把你公司的邮件通道打爆。即使不考虑攻击正常用户手抖点三下发送按钮也会收到三封邮件。我在发送逻辑里加了两层限制同一邮箱的冷却60秒内只允许发一次IP维度的兜底限流某个IP的发送总量按小时控制。冷却用Redis的setIfAbsent实现逻辑如下public void sendLoginCode(String email) { email email.toLowerCase(); String coolKey login:cooldown: email; Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(coolKey, 1, 60, TimeUnit.SECONDS); if (Boolean.FALSE.equals(first)) { throw new BusinessException(429, 发送太频繁请稍后再试); } try { String code generateCode(); String key login:captcha: email; stringRedisTemplate.opsForValue().set(key, code, 300, TimeUnit.SECONDS); mailService.sendCodeMail(email, code); } catch (Exception e) { stringRedisTemplate.delete(coolKey); stringRedisTemplate.delete(login:captcha: email); throw new BusinessException(500, 验证码发送失败请检查邮箱地址后重试); } }这里有一个细节很多人会忽略邮件发送是网络操作可能失败。失败时必须把冷却Key和验证码Key都删除否则用户要白白等60秒甚至5分钟才能重试。邮件发送失败的异常类型主要是MailAuthenticationException、MailSendException捕获Exception是一种简单兜底生产环境里可以根据异常类型拆更细。后端的邮箱格式校验也不能只靠前端。我用Hibernate Validator的Email注解Controller接收参数时直接完成格式校验避免格式明显不对的邮箱也触发SMTP连接。3. 邮箱登录从发送验证码到签发JWT的实现细节3.1 发送验证码接口的后端实现Controller层尽量保持薄。接口定义如下RestController RequestMapping(/api/auth) public class AuthController { PostMapping(/email/send-code) public ResultVoid sendEmailCode(RequestBody Valid SendCodeRequest request) { captchaService.sendLoginCode(request.getEmail()); return Result.ok(); } }Request对象里只需要一个邮箱字段public class SendCodeRequest { NotBlank(message 邮箱不能为空) Email(message 邮箱格式不正确) private String email; // getter/setter }统一返回结构我用ResultT包含code/message/data。前端拦截器按code处理业务状态比如200表示成功429表示频率限制500表示系统异常。这样一套结构在前后端联调时很清爽。3.2 校验登录与Token签发用户输入验证码后后端要做四件事校验验证码是否存在、比对内容、删除Key、查询或创建用户。我当前系统采用“邮箱即账户”的策略第一次用邮箱验证码登录时自动创建用户这样用户不需要先注册再登录邮箱本身就是身份。如果产品要求必须先有账号把创建用户这段去掉即可。登录校验的核心代码public LoginResult loginByEmail(String email, String code) { email email.toLowerCase(); String key login:captcha: email; String saved stringRedisTemplate.opsForValue().get(key); if (StringUtils.isBlank(saved)) { throw new BusinessException(400, 验证码不存在或已过期); } if (!saved.equals(code.trim())) { throw new BusinessException(400, 验证码错误); } stringRedisTemplate.delete(key); User user userMapper.selectByEmail(email); if (user null) { user User.builder() .email(email) .passwordHash() .passwordVersion(0) .build(); userMapper.insert(user); } String token jwtService.createToken(user); return LoginResult.builder().token(token).email(email).build(); }这里有一个容易忽视的小点验证码比对前应做trim因为用户可能复制时带上空格。验证码本身是纯数字但用户从邮件里复制的时候偶发会多出空格不做trim会造成明明正确却提示错误。JWT签发这里有一个值得分享的方法把用户的passwordVersion作为claim写进token里。这样后续如果用户重置了密码所有基于旧passwordVersion签发的token会立刻失效。这个方案比维护黑名单简单得多也足够覆盖邮箱验证码这种中低风险场景。3.3 前端登录页与倒计时处理前端我用的是Vue 3模板部分不复杂核心是验证码那一行的发送验证码按钮要处理倒计时、禁用状态、组件卸载后的定时器清理。为了不引入额外UI组件库我用原生input做示例template div classlogin-wrapper div classrow input v-modelform.email placeholder请输入邮箱 / /div div classrow code-row input v-modelform.code placeholder请输入验证码 / button :disabledcounting clicksendCode {{ counting ? ${countdown}s后重发 : 发送验证码 }} /button /div button typebutton clickhandleLogin登录/button /div /template对应script区域import { reactive, ref, onBeforeUnmount } from vue import { sendEmailCode, emailLogin } from /api/auth export default { setup() { const form reactive({ email: , code: }) const counting ref(false) const countdown ref(60) let timer null const sendCode async () { if (!form.email) return await sendEmailCode({ email: form.email }) counting.value true countdown.value 60 timer setInterval(() { countdown.value-- if (countdown.value 0) { clearInterval(timer) counting.value false } }, 1000) } onBeforeUnmount(() { if (timer) clearInterval(timer) }) const handleLogin async () { const res await emailLogin({ email: form.email, code: form.code }) localStorage.setItem(token, res.data.token) // 跳转到首页 } return { form, counting, countdown, sendCode, handleLogin } } }不要小看onBeforeUnmount里清理定时器这行代码。如果用户发送验证码后不等倒计时结束就切换路由组件被销毁但定时器还在跑轻则报错重则产生多余闭包。这类问题在SPA里很常见排查起来也不直观。更进一步倒计时如果只依赖前端变量刷新页面后会从头开始。真要做得稳妥应该在发送成功后把下次可发送时间存到localStorage页面加载时读取剩余时间。这个属于体验优化不做不影响主流程。3.4 前端请求封装与错误提示axios封装建议统一做因为验证码登录会出现几种不同的失败状态验证码错误可以清空验证码框让用户重输验证码过期要引导重新发送发送太频繁则保持按钮倒计时禁止状态。我在项目里使用axios响应拦截器统一处理业务码接口失败时至少保证前端不是白屏。4. 忘记密码重置流程与旧Token失效处理4.1 忘记密码的接口流程忘记密码的流程拆成两个接口。第一个是POST /api/auth/password/send-reset-code接收邮箱并发送重置验证码。第二个是POST /api/auth/password/reset接收邮箱、验证码和新密码校验通过后更新密码。第一个接口和登录验证码的发送逻辑几乎一样只是Redis Key前缀换成reset。真正的区别在第二个接口校验通过后不再签发token而是调用passwordEncoder更新用户密码同时把passwordVersion加一。重置密码的核心代码public void resetPassword(String email, String code, String newPassword) { email email.toLowerCase(); String key reset:captcha: email; String saved stringRedisTemplate.opsForValue().get(key); if (StringUtils.isBlank(saved) || !saved.equals(code.trim())) { throw new BusinessException(400, 验证码错误或已过期); } User user userMapper.selectByEmail(email); if (user null) { throw new BusinessException(400, 用户不存在); } userMapper.updatePassword(user.getId(), passwordEncoder.encode(newPassword)); userMapper.updatePasswordVersion(user.getId()); stringRedisTemplate.delete(key); }这里有一个顺序问题如果先删Key再更新密码万一更新失败用户要重新发送验证码。如果先更新密码再删Key万一删除失败旧验证码还会残留到下个5分钟过期。我更推荐先更新密码再删除Key因为删除Key失败的概率远小于数据库更新失败的概率而且即使删除失败验证码也会自动过期。4.2 密码重置后如何让旧Token失效很多实践文章讲到重置密码就结束了但这里有一个隐患用户重置密码后以前签发的JWT仍然有效除非它刚好过期。如果一个攻击者之前拿到了合法token重置密码并不会把他踢出去。忘记密码功能本就是为了找回被盗账号旧token继续有效显然不合理。我用passwordVersion字段解决这个问题。用户表里多存一个整型字段每次签发token时把这个值作为claim写入。JWT拦截器解析token拿到userId后去数据库查询当前用户版本两者不一致就判定token失效。密码重置成功后版本加一所有旧token瞬间作废。校验逻辑示意public boolean validateToken(String token) { Claims claims parseToken(token); Long userId Long.valueOf(claims.getSubject()); Integer tokenVersion claims.get(passwordVersion, Integer.class); User user userMapper.selectById(userId); return user ! null Objects.equals(user.getPasswordVersion(), tokenVersion); }这个方案不完美每次请求JWT校验都会查一次数据库。但对中后台系统来说成本可以接受而且比维护token黑名单干净得多。如果想要更短的验证路径可以把版本号放进Redis缓存但不是必须。4.3 前端重置密码页面注意事项前端重置密码页和登录页结构类似额外增加确认密码栏。需要处理的无非是几个常规操作新密码和确认密码不一致时要给出明确提示重置成功后跳转登录页如果本地之前存了token必须清理干净否则用户会误以为自己还在登录状态。重置成功后同一验证码不能二次使用因为后端已经删掉Key前端文案不用特别提示直接跳转登录页即可。5. 联调排坑实录验证码收不到、过期、跨域这些坑5.1 邮件发不出去与进垃圾箱的原因整个功能联调时最先遇到的一定是邮件问题。常见的有几种情况。第一种是SMTP连接超时或认证失败。连接超时多半是本地网络把465端口或587端口限制了公司网络策略尤其常见。认证失败通常是授权码配错或邮箱服务商没开启SMTP报错信息会出现AuthenticationFailedException。可以换一台网络环境测试或者用服务器端验证是否能正常访问SMTP端口。第二种是邮件发出去但用户收不到因为进了垃圾箱。免费邮箱对发件域名、邮件正文格式都有判断规则。邮件里纯数字验证码没有链接通常不会被判定为钓鱼但如果发件域名没有配置SPF记录被当垃圾邮件的概率会明显增加。自有域名发邮件先检查DNS里有没有SPF记录。公司邮件系统一般不用自己操心这个。本地开发时我建议先对接一个本地Mock SMTP工具把收件地址和发送动作架空先专注调试业务逻辑等验证码流程跑通后再切换真实SMTP。这样可以避免每次联调都被网络环境折腾。5.2 验证码明明没用在服务端却查不到联调中第二个高频问题前端说验证码已发送后端Redis里也能查到但提交登录时却提示“验证码不存在或已过期”。排查方向按可能性排序Key序列化问题。RedisTemplate默认使用JDK序列化Key会带二进制前缀。用StringRedisTemplate或者把RedisTemplate的Serializer改成StringRedisSerializer。邮箱大小写不一致。发送时邮箱是abcexample.com提交时用的是ABCexample.com字符串拼接的Key不同。统一转小写最省事。多环境Redis地址配错。本地应用连着测试服务器的Redis你在本地看当然查不到同一个Key。验证码被消费过了。比如同一个验证码在页面上被双击提交两次第二次自然提示过期。这类问题如果按“先清空Redis再重新发送”的思路去处理只能掩盖问题。建议从Key的生成规则和序列化配置入手排查。5.3 开发环境跨域与Cookie的坑Vue开发服务器默认跑在某个端口后端跑在另一个端口浏览器跨域是绕不开的。我在开发环境用的是前端代理而不是后端CORS硬开。原因是CORS配置再多生产环境通常也是通过Nginx或网关同域转发开发环境用代理更贴近线上路径。Vite的配置片段export default defineConfig({ server: { port: 8080, proxy: { /api: { target: http://127.0.0.1:8081, changeOrigin: true } } } })如果确实要用CORS有几个坑必须清楚allowedOrigins不要配置成*因为allowCredentials(true)时Spring会拒绝通配符来源。用Cookie方案做跨域登录态前端请求要带withCredentials并且Cookie的SameSite属性要处理。开发环境用HTTP时Secure Cookie也会带来麻烦。所以项目里我用Authorization头放JWT绕开了Cookie这一堆问题。5.4 异步发送邮件的注意事项业务量涨上去之后同步发送邮件会明显拖慢接口响应。SMTP握手加发送快则几百毫秒慢则几秒。用户点击发送验证码页面转圈两三秒体验很不好。把邮件发送改成异步是常规做法。如果直接在邮件Service方法上加Async要小心两个点默认线程池是SimpleAsyncTaskExecutor它不会复用线程并发一高就会不断创建线程建议专门配置ThreadPoolTaskExecutor异步方法的异常不会抛给调用方发送失败时调用方已经返回成功用户根本不知道需要在异步方法内部捕获异常并记录日志同时支持重试。我的建议是验证码发送这种关键链路如果团队中间件能力允许优先放到消息队列处理发送结果通过状态表跟踪。如果只是中小型系统先同步发送把逻辑做对再考虑异步也不迟。5.5 本地调试的小技巧最后分享一个提高调试效率的点。我在本地开发时会在验证码Service里留一个debug配置。当配置开关开启时发送验证码接口除了真实发送邮件还把验证码写到单独的Redis Key里方便直接读Redis确认验证码已生成。线上默认关闭。这个开关一定要通过多环境配置控制绝不能跟前端约定从接口返回验证码。否则等于把验证码暴露给接口调用者安全上完全不可接受。6. 安全加固与上线建议不能只停留在“能用”6.1 图形验证码与接口限流验证码发送接口的真正风险不是普通用户手快而是脚本批量调用。只要接口能被脚本拿到别人就能用你的邮箱系统发大量垃圾邮件甚至把发件账号刷到SMTP限流封禁。我建议在上线前补一道防线在发送验证码之前增加图形验证码或滑块验证码。图形验证码的后端实现不复杂Java原生BufferedImage就能画干扰线答案存Redis并绑定一个uuid前端展示图片提交时带上uuid和用户输入。不用接第三方服务成本很低。IP维度的限流也应该做。简单方式是Redis的INCR加EXPIREString ipKey login:send: clientIp; Long count stringRedisTemplate.opsForValue().increment(ipKey); if (count 1) { stringRedisTemplate.expire(ipKey, 1, TimeUnit.HOURS); } if (count 20) { throw new BusinessException(429, 请求过于频繁); }6.2 验证码失败次数限制与统一应答验证码只有6位数字理论上100万种组合如果不限制错误次数攻击者可以在5分钟内暴力尝试。Redis里可以同时存一个失败计数或者把验证码和失败次数拼成一个值比如“123456:0”校验失败时加一超过5次就删除验证码并提示重新获取。这个设计对撞库脚本有很强的阻断作用。响应文案要尽量统一。例如发送验证码接口不管邮箱是否存在统一返回“发送成功”。这样做是为了防止通过接口枚举有效邮箱。业务确实需要区分时建议在登录结果里引导注册而不是在验证码发送这一步暴露用户是否存在。日志脱敏也是安全的一部分。邮箱在日志里打印成y**example.com这种形式验证码不允许完整打印用户密码必须存储BCrypt加密后的结果。这些细节在代码评审时就要盯着不要等上线后再补。6.3 监控告警与多环境隔离上线后需要盯着几个关键指标邮件发送成功率、验证码发送接口QPS、Redis中验证码Key的峰值数量。如果某个时段发送量突然暴增大概率是攻击脚本在跑如果邮件发送成功率骤降可能是SMTP账号被限制。用简单的日志采集加告警规则就能发现问题不一定一开始就上复杂链路。最后提醒一个数据库层的点用户表的邮箱字段务必建唯一索引。邮箱验证码登录存在自动建号逻辑并发场景下两个请求同时发现用户不存在都去insert可能出现重复邮箱记录。唯一索引兜底后插入失败的那一侧再去查询一次即可。做完这个功能我自己最大的体会是验证码不是一个简单的一次性字符串它是有生命周期的独立资源从生成、存储、过期、消费到异常回滚每一步都值得单独设计。如果新建项目建议先把全链路跑通再优化细节本地用Mock SMTP、Redis起一个实例、前端放一个按钮尽早把整个数据流转起来。最后一个小技巧邮件模板里可以加上用户登录的客户端信息或地域信息方便用户识别异常操作也方便客服定位问题。
阅读完成 · 觉得有帮助?