ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot+Vue邮箱验证码登录与忘记密码全流程解析

Spring Boot+Vue邮箱验证码登录与忘记密码全流程解析

做完登录模块的人应该都有同感:把“发验证码”挂到接口上只需要几十行代码,真正麻烦的是验证码生命周期、重复发送的防刷、用户输错后的处理、密码找回之后旧登录态怎么作废这些边角问题。最近我用Springboot+Vue给某内部系统补上了邮箱验证码登录和忘记密码功能,整条链路走下来踩了不少坑,今天把方案设计和排坑过程整理出来。这篇文章适合正在做类似登录模块的开发者,也适合想了解邮箱验证码完整实现思路的产品和测试同学。

整套方案骨架不复杂:用户输入邮箱,后端生成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,保证验证码是一次性的,随后执行登录逻辑。

这个过程里容易引发争议的点有三个:

  1. 验证码必须一次性使用,校验成功或过期后旧的验证码要立即失效。
  2. 有效期不能太长,5分钟是比较常规的折中值,太短用户来不及切邮箱,太长安全风险增大。
  3. 冷却必须生效,没有冷却的发送接口可以被脚本刷成邮件炸弹。

举个生活化的例子:验证码就像一次性门禁卡,进门一次就作废,而且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的序列化方式。如果项目里用的是默认RedisTemplate,Key会被JDK序列化加一串二进制前缀,在命令行看Key会非常痛苦。我建议使用StringRedisTemplate,Key和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-reply@example.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-reply@example.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开关,但上线前必须关闭。如果确实需要在日志里定位问题,最多打印后两位,比如“code=123456”输出成“***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 Result<Void> 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 }

统一返回结构我用Result<T>,包含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 class="login-wrapper"> <div class="row"> <input v-model="form.email" placeholder="请输入邮箱" /> </div> <div class="row code-row"> <input v-model="form.code" placeholder="请输入验证码" /> <button :disabled="counting" @click="sendCode"> {{ counting ? `${countdown}s后重发` : '发送验证码' }} </button> </div> <button type="button" @click="handleLogin">登录</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里也能查到,但提交登录时却提示“验证码不存在或已过期”。排查方向按可能性排序:

  1. Key序列化问题。RedisTemplate默认使用JDK序列化,Key会带二进制前缀。用StringRedisTemplate,或者把RedisTemplate的Serializer改成StringRedisSerializer。
  2. 邮箱大小写不一致。发送时邮箱是abc@example.com,提交时用的是ABC@example.com,字符串拼接的Key不同。统一转小写最省事。
  3. 多环境Redis地址配错。本地应用连着测试服务器的Redis,你在本地看当然查不到同一个Key。
  4. 验证码被消费过了。比如同一个验证码在页面上被双击提交两次,第二次自然提示过期。

这类问题如果按“先清空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加EXPIRE:

String 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起一个实例、前端放一个按钮,尽早把整个数据流转起来。最后一个小技巧:邮件模板里可以加上用户登录的客户端信息或地域信息,方便用户识别异常操作,也方便客服定位问题。

RELATED READING

延伸阅读

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