Spring Boot 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Spring Boot 里做注册/登录邮箱验证码:SecureRandom、HMAC 落 ConcurrentHashMap/Redis/JPA、限流、用 RestClient 或 WebClient 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 Jakarta Mail;Rovela 只负责投递。
为什么用 Spring Boot + 邮箱验证码(而不是 JavaMail SDK)
Spring Boot 本来就适合做注册/登录:@RestController、过滤器/拦截器限流,以及服务端存储(ConcurrentHashMap、Spring Data Redis 或 JPA)。你不需要 Rovela 的 Java SDK、「OTP 产品」接口,或把邮件 API 当成身份提供商的 starter。
你需要的是文档里的配方——邮箱验证码:应用用 SecureRandom 生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再用 Spring 的 RestClient(Boot 3.2+)或 WebClient 调 POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。
Rovela 不提供 OTP 功能。 Spring 服务负责密码学、存储条目、重发节流、核销逻辑以及 session cookie / JWT。Rovela 只负责投递。
本文面向 Spring Boot(3.x / Java 17+):「发码」与「验码」控制器、薄封装的 Rovela 客户端 Bean、内存或 Redis 的 HMAC 存储,以及 Idempotency-Key,避免超时重试入队两封不一致的码。这不是魔法链接教程——魔法链接见 Next.js、NestJS、Rails、FastAPI。同款 OTP 姊妹篇:Express.js、Django、Laravel、Go。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。通用 Java / Kotlin 发送(非 OTP)见 Java 发邮件 与 Kotlin 发邮件——本文不重复 Jakarta Mail SMTP、MIME 拼装或「hello world」发送。
你负责什么,Rovela 发什么
Spring Boot 应用负责:
SecureRandom→ 六位、左侧补零(不要用Math.random()/Random)- 用
Mac.getInstance("HmacSHA256")对email + ":" + code做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
- 按规范化邮箱 upsert,新发会使旧码失效(
ConcurrentHashMap、Redis 或 JPA) - 核销时用
MessageDigest.isEqual常量时间比较;成功后 delete / 失效 - 是否暴露账号存在性由产品决定,但公开响应宜保持一致
- 对
/auth/otp/send与/auth/otp/verify做限流
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(环境变量里的ROVELA_API_KEY——勿提交、勿放进公开配置、勿打印到日志)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
配置与薄封装客户端(RestClient)
# application.properties(或环境变量 / 密钥管理)
rovela.api-key=${ROVELA_API_KEY}
rovela.from=Acme <security@mail.acme.com>
otp.code-pepper=${CODE_PEPPER}
otp.ttl-minutes=10
otp.resend-cooldown-seconds=60
otp.max-attempts=5
// RovelaProperties.java
package com.example.otp;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "rovela")
public record RovelaProperties(String apiKey, String from) {}
// OtpProperties.java
package com.example.otp;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "otp")
public record OtpProperties(
String codePepper,
int ttlMinutes,
int resendCooldownSeconds,
int maxAttempts
) {}
// RovelaMailClient.java
package com.example.otp;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class RovelaMailClient {
private final RestClient http;
private final RovelaProperties props;
public RovelaMailClient(RestClient.Builder builder, RovelaProperties props) {
this.props = props;
this.http = builder.baseUrl("https://api.rovela.dev").build();
}
public Map<?, ?> send(String to, String subject, String html, String text, String idempotencyKey) {
return http.post()
.uri("/emails")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + props.apiKey())
.header("Idempotency-Key", idempotencyKey)
.header("Accept", "application/json")
.body(Map.of(
"from", props.from(),
"to", to,
"subject", subject,
"html", html,
"text", text
))
.retrieve()
.body(Map.class);
}
}
// WebClient 替代写法(响应式栈)
// webClient.post().uri("https://api.rovela.dev/emails")
// .header("Authorization", "Bearer " + apiKey)
// .header("Idempotency-Key", key)
// .bodyValue(payload)
// .retrieve()
// .bodyToMono(Map.class);
像对待其他服务端密钥一样配置 ROVELA_API_KEY 与 CODE_PEPPER(Authentication、Quick Start)。生产 OTP 发送端用 sending_access 密钥就够——也更合适。用 @EnableConfigurationProperties({RovelaProperties.class, OtpProperties.class}) 启用配置。
落库要哈希:SecureRandom + HMAC,不要裸 SHA-256
六位码只有一百万种可能。泄露表里若是裸 SHA-256,一秒内就能反推。按文档来:用永远不离开服务器的 pepper 做 HMAC。
// OtpCrypto.java
package com.example.otp;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class OtpCrypto {
private static final SecureRandom RNG = new SecureRandom();
private OtpCrypto() {}
public static String normalizeEmail(String raw) {
return raw == null ? "" : raw.trim().toLowerCase();
}
public static String mintCode() {
// CSPRNG — Math.random() / java.util.Random 不适合密钥
int n = RNG.nextInt(1_000_000);
return String.format("%06d", n);
}
public static String hashCode(String pepper, String email, String code) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(pepper.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] out = mac.doFinal((email + ":" + code).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(out);
} catch (Exception e) {
throw new IllegalStateException("HMAC failed", e);
}
}
public static boolean safeEqualHex(String a, String b) {
byte[] ab = HexFormat.of().parseHex(a);
byte[] bb = HexFormat.of().parseHex(b);
return MessageDigest.isEqual(ab, bb);
}
}
建议表结构(每个邮箱一条有效码),与验证码文档一致:
CREATE TABLE verification_codes (
email TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
演示可用按邮箱键控的 ConcurrentHashMap(单 JVM)。多副本请用 Redis(StringRedisTemplate)或 Postgres。
服务:发送 + 校验(ConcurrentHashMap 存储)
// OtpRecord.java
package com.example.otp;
import java.time.Instant;
record OtpRecord(String codeHash, int attempts, Instant expiresAt, Instant sentAt) {
OtpRecord withAttempts(int n) {
return new OtpRecord(codeHash, n, expiresAt, sentAt);
}
}
// OtpService.java
package com.example.otp;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
@Service
public class OtpService {
private static final String SAFE_SEND_MSG =
"If that address can receive mail, a code is on its way.";
private final OtpProperties cfg;
private final RovelaMailClient mail;
private final ConcurrentHashMap<String, OtpRecord> store = new ConcurrentHashMap<>();
public OtpService(OtpProperties cfg, RovelaMailClient mail) {
this.cfg = cfg;
this.mail = mail;
}
public Map<String, Object> send(String rawEmail) {
String email = OtpCrypto.normalizeEmail(rawEmail);
Instant now = Instant.now();
OtpRecord existing = store.get(email);
if (existing != null
&& now.isBefore(existing.sentAt().plusSeconds(cfg.resendCooldownSeconds()))) {
return Map.of("ok", false, "error", "too_soon");
}
String code = OtpCrypto.mintCode();
String codeHash = OtpCrypto.hashCode(cfg.codePepper(), email, code);
store.put(
email,
new OtpRecord(
codeHash,
0,
now.plusSeconds(cfg.ttlMinutes() * 60L),
now));
// 同一逻辑发送用稳定 key:哈希前缀。新码 ⇒ 新 key。
String idem = "otp-" + codeHash.substring(0, 32);
int ttlMin = cfg.ttlMinutes();
String html =
"<p>Your verification code is</p>"
+ "<p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">"
+ code
+ "</p>"
+ "<p>It expires in "
+ ttlMin
+ " minutes. If you did not ask for it, ignore this email.</p>";
String text =
"Your verification code is "
+ code
+ ". It expires in "
+ ttlMin
+ " minutes. If you did not ask for it, ignore this email.";
mail.send(email, "Your Acme code: " + code, html, text, idem);
return Map.of("ok", true, "message", SAFE_SEND_MSG);
}
public Map<String, Object> verify(String rawEmail, String rawCode) {
String email = OtpCrypto.normalizeEmail(rawEmail);
String code = rawCode == null ? "" : rawCode.trim();
if (code.length() != 6 || !code.chars().allMatch(Character::isDigit)) {
return Map.of("ok", false, "error", "invalid");
}
OtpRecord rec = store.get(email);
if (rec == null) {
return Map.of("ok", false, "error", "invalid");
}
Instant now = Instant.now();
if (now.isAfter(rec.expiresAt())) {
store.remove(email);
return Map.of("ok", false, "error", "expired");
}
if (rec.attempts() >= cfg.maxAttempts()) {
return Map.of("ok", false, "error", "locked");
}
String actual = OtpCrypto.hashCode(cfg.codePepper(), email, code);
if (!OtpCrypto.safeEqualHex(actual, rec.codeHash())) {
store.put(email, rec.withAttempts(rec.attempts() + 1));
return Map.of("ok", false, "error", "invalid");
}
store.remove(email);
// 在此签发 session / JWT
return Map.of("ok", true);
}
}
把码放进主题行方便锁屏预览——多数产品可以;共享屏幕场景可去掉(验证码文档)。务必同时发送 html 与 text,且明文部分包含数字(POST /emails)。
Redis:SET otp:{email} {json} 并设 TTL,另加 SET otp:cooldown:{email} 1 EX 60。JPA/SQL:按 email upsert,在事务里递增 attempts,成功后 DELETE。
控制器 + 限流
// OtpController.java
package com.example.otp;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/auth/otp")
public class OtpController {
private final OtpService svc;
public OtpController(OtpService svc) {
this.svc = svc;
}
public record SendBody(String email) {}
public record VerifyBody(String email, String code) {}
@PostMapping("/send")
public ResponseEntity<?> send(@RequestBody SendBody body) {
if (body.email() == null || body.email().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "invalid_email"));
}
try {
Map<String, Object> out = svc.send(body.email());
if (Boolean.FALSE.equals(out.get("ok"))) {
if ("too_soon".equals(out.get("error"))) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "too_soon"));
}
}
return ResponseEntity.ok(Map.of("message", out.get("message")));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "send_failed"));
}
}
@PostMapping("/verify")
public ResponseEntity<?> verify(@RequestBody VerifyBody body) {
Map<String, Object> out = svc.verify(body.email(), body.code());
if (Boolean.FALSE.equals(out.get("ok"))) {
HttpStatus status =
"locked".equals(out.get("error")) || "too_soon".equals(out.get("error"))
? HttpStatus.TOO_MANY_REQUESTS
: HttpStatus.BAD_REQUEST;
return ResponseEntity.status(status).body(Map.of("error", out.get("error")));
}
return ResponseEntity.ok(Map.of("ok", true));
}
}
对 /auth/otp/** 加 Bucket4j / Resilience4j 过滤器(或前置 Cloudflare / nginx)做按 IP 限流。服务内按邮箱冷却与按 IP 限流解决不同问题——两者都要。加固清单见 邮箱 OTP 安全。
Idempotency-Key:重试不发双码
Worker、反向代理和 AI agent 都会重试。没有 Idempotency-Key,一次超时可能让用户收到两封不一致的码。
- Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原响应(API: emails)。
- 从码哈希派生(
otp-+ 前 32 个十六进制字符),保证一次逻辑发码对应一封入队邮件。 - 冷却后有意重发会 mint 新码 → 新哈希 → 新 key。不要用旧 key 发新码。
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-spring-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "you@example.com",
"subject": "Your Acme code: 482913",
"html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
"text": "Your verification code is 482913. Expires in 10 minutes."
}'
为什么 Spring 发 OTP 不要用 Jakarta Mail SMTP
RestClient / WebClient → POST /emails(本文) | Jakarta Mail / Spring JavaMailSender | |
|---|---|---|
| 传输 | HTTPS 443 | 465 / 587(25 常被封) |
| 幂等 | Idempotency-Key 头 | 应用自己去重 |
| 反馈 | 消息 id + webhook | 稍后 bounce DSN |
| 容器 / K8s | 443 通常畅通 | SMTP 常受限 |
| OTP 适配 | JSON html + text | 手拼 MIME |
已有 SMTP 依赖可以继续用。新的注册/登录数字码请调 HTTP API。更广的 Java 发送模式见 Java 发邮件。
认证数字码的投递注意(不是魔法链接)
OTP 邮件短且敏感。优先用已验证子域如 security@mail.example.com,对齐 SPF/DKIM/DMARC,HTML 尽量干净。码在正文(常也在主题)里,一般不需要可点链接——因此点击追踪改写对魔法链接更关键——但打开/点击追踪设置仍应落在你控制的域名上。
若进垃圾箱,按 为什么 OTP / 魔法链接进垃圾箱 排查。产品该用数字码还是确认链接,见 验证码 vs 链接。
Spring Boot 里:验证码 vs 魔法链接
| 六位 OTP(本文) | 魔法链接 | |
|---|---|---|
| 用户动作 | 输入数字 | 点击 URL |
| 秘密熵 | 低 → 必须 HMAC + pepper | URL token 熵高 → 通常 SHA-256 token 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Spring 表面 | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 次要 | 安全发信域上建议关闭 |
两者都调同一个 POST /emails。存储分开——不要把 OTP 记录当魔法链接 token 复用。
上线前清单
SecureRandom+ HMAC pepper;永不存明文码;OTP 不用Math.random()。- 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
- Upsert / 覆盖使旧码失效;校验成功后删除。
- 同时发
html和text;主题行可选带码便于预览。 ROVELA_API_KEY(sending_access)只放服务端。Idempotency-Key对单次逻辑发送保持稳定;新码 → 新 key。- 若隐藏账号存在性,公开文案保持一致。
- OTP 优先 HTTP API 而非 Jakarta Mail SMTP;发信域已验证;进垃圾箱时用 spam 清单。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
- UX 选择:邮箱验证:验证码 vs 链接。
- 投递:为什么 OTP / 魔法链接进垃圾箱。
- 框架姊妹篇:Express OTP · Django OTP · Laravel OTP · Go OTP · 魔法链接 Next.js / NestJS / Rails / FastAPI。
- 仅 Java / Kotlin 发送:Java 发邮件 · Kotlin 发邮件。
**一句话:**在 Spring Boot 控制器里用 SecureRandom 发注册/登录 OTP,HMAC 落库(ConcurrentHashMap、Redis 或 JPA),限流发/验,再用带 Idempotency-Key 的一次 RestClient/WebClient POST 调 Rovela——优先于 Jakarta Mail SMTP,也永远不要把邮件 API 当成你的认证服务器。