用戶註冊郵箱驗證、忘記密碼重置是 Web 應用標配。本文基於 Spring Boot 2/3 + JavaMailSender,提供從郵件發送、Token 生成、鏈接構建、安全校驗到頁面跳轉的完整端到端實現方案。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- Redis 做 Token 存儲(推薦) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
mail:
host: smtp.exmail.qq.com
port: 465
username: noreply@yourdomain.com
password: your_smtp_password
protocol: smtps
properties:
mail:
smtp:
ssl:
enable: true
auth: true
timeout: 10000
redis:
host: localhost
port: 6379
@Service
@RequiredArgsConstructor
public class MailService {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine; // Thymeleaf
public void sendHtmlMail(String to, String subject, String templateName, Context context) {
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setTo(to);
helper.setSubject(subject);
String html = templateEngine.process(templateName, context);
helper.setText(html, true);
mailSender.send(message);
} catch (MessagingException e) {
throw new MailSendException("郵件發送失敗: " + e.getMessage(), e);
}
}
}
@PostMapping("/register")
public Result register(@Valid @RequestBody RegisterDTO dto) {
// 1. 保存用戶,status=INACTIVE
User user = userService.saveInactive(dto);
// 2. 生成激活 Token(JWT 或隨機字符串 + Redis)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set("email:activate:" + token, user.getId(), 24, TimeUnit.HOURS);
// 3. 構建激活鏈接
String activateUrl = String.format("%s/activate?token=%s", frontendBaseUrl, token);
// 4. 發送郵件
Context ctx = new Context();
ctx.setVariable("username", user.getUsername());
ctx.setVariable("activateUrl", activateUrl);
ctx.setVariable("expireHours", 24);
mailService.sendHtmlMail(user.getEmail(), "激活您的賬號", "mail/activate", ctx);
return Result.success("註冊成功,請查收郵件激活");
}
@GetMapping("/activate")
public String activate(@RequestParam String token, Model model) {
String userIdStr = redisTemplate.opsForValue().get("email:activate:" + token);
if (userIdStr == null) {
model.addAttribute("msg", "激活鏈接無效或已過期");
return "activate_fail";
}
Long userId = Long.parseLong(userIdStr);
User user = userService.getById(userId);
if (user == null || user.getStatus() == UserStatus.ACTIVE) {
model.addAttribute("msg", "用戶不存在或已激活");
return "activate_fail";
}
userService.activate(userId);
redisTemplate.delete("email:activate:" + token); // 一次性 Token
model.addAttribute("msg", "激活成功,請登錄");
return "activate_success";
}
resources/templates/mail/activate.html<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<body style="font-family: 'Microsoft YaHei', sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
<h2 style="color: #333;">歡迎註冊,<span th:text="${username}">用戶</span></h2>
<p>請點擊下方按鈕激活您的郵箱(鏈接有效期 <span th:text="${expireHours}">24</span> 小時):</p>
<div style="text-align: center; margin: 30px 0;">
<a th:href="${activateUrl}"
style="background: #1890ff; color: white; padding: 12px 24px; border-radius: 4px; text-decoration: none; display: inline-block;">
立即激活
</a>
</div>
<p style="color: #999; font-size: 12px;">若按鈕無法點擊,請複製鏈接至瀏覽器:<br/><span th:text="${activateUrl}"></span></p>
</div>
</body>
</html>
@PostMapping("/forgot-password")
public Result forgotPassword(@RequestParam String email) {
User user = userService.findByEmail(email);
if (user == null) {
// 安全:不提示用戶是否存在,統一返回成功
return Result.success("若郵箱存在,重置鏈接已發送");
}
// 生成 6 位數字驗證碼,存 Redis 15 分鐘
String code = String.format("%06d", new Random().nextInt(1_000_000));
redisTemplate.opsForValue().set("pwd:reset:" + email, code, 15, TimeUnit.MINUTES);
Context ctx = new Context();
ctx.setVariable("code", code);
ctx.setVariable("username", user.getUsername());
ctx.setVariable("expireMinutes", 15);
mailService.sendHtmlMail(email, "密碼重置驗證碼", "mail/reset_code", ctx);
return Result.success("若郵箱存在,重置鏈接已發送");
}
@PostMapping("/reset-password")
public Result resetPassword(@Valid @RequestBody ResetPasswordDTO dto) {
// 1. 校驗驗證碼
String cachedCode = redisTemplate.opsForValue().get("pwd:reset:" + dto.getEmail());
if (cachedCode == null || !cachedCode.equals(dto.getCode())) {
return Result.error("驗證碼錯誤或已過期");
}
// 2. 更新密碼(BCrypt 加密)
userService.updatePassword(dto.getEmail(), passwordEncoder.encode(dto.getNewPassword()));
// 3. 刪除驗證碼,強制登出(刪除所有 Refresh Token)
redisTemplate.delete("pwd:reset:" + dto.getEmail());
tokenService.revokeAllUserTokens(dto.getEmail());
return Result.success("密碼重置成功,請重新登錄");
}
| 風險點 | 防範措施 |
|---|---|
| Token 洩露 | 使用 UUID + Redis 存儲,不直接在鏈接攜帶 userId;設置過期時間(激活 24h,重置 15min) |
| 枚舉用戶郵箱 | 忘記密碼接口統一返回成功提示,不區分郵箱是否存在 |
| 暴力破解驗證碼 | 限流:同 IP/郵箱 1 分鐘 1 次,1 小時 5 次;驗證碼 6 位數字 + 15 分鐘過期 |
| 重置後會話劫持 | 重置成功後撤銷所有 Refresh Token,強制重新登錄 |
| 郵件內容注入 | Thymeleaf 自動轉義,禁止在模板中直接輸出未轉義變量 th:utext |
disabled 狀態防止重複發送郵箱激活與密碼重置核心在於「一次性 Token + 時效性 + 安全校驗」。Spring Boot + JavaMail + Redis + Thymeleaf 組合開箱即用,關鍵細節:統一錯誤提示防枚舉、重置後撤銷 Token、限流防暴力、模板引擎防注入。按此框架擴展即可支援手機短信、多語言、單點登錄等高級場景。