Quarkus 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Quarkus 里做注册/登录邮箱验证码:SecureRandom、HMAC 落 ConcurrentHashMap/Redis/SQL、限流、用 MicroProfile REST Client 或 HttpClient 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 Mailer SMTP;对照 Spring Boot 兄弟篇;Rovela 只负责投递。
为什么用 Quarkus + 邮箱验证码(而不是 Jakarta Mail)
Quarkus 本来就适合做注册/登录:JAX-RS / Quarkus REST 资源、CDI Bean、SmallRye 配置,以及服务端存储(ConcurrentHashMap、quarkus-redis-client,或 Hibernate ORM)。你不需要 Rovela 的 Java SDK,也不需要什么「OTP 产品接口」,更不要把邮件 API 当成身份提供商。
你真正需要的是文档里的配方:邮箱验证码示例。应用侧用 SecureRandom 生成六位数字,只存 HMAC(绝不存明文),设置过期与尝试次数,再用 MicroProfile REST Client 或 Java HttpClient 调 POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM、重试与事件记录。它不会生成验证码、校验验证码,也不会创建会话。
Rovela 没有 OTP 功能。 密码学、落库、重发节流、核销与 Session/JWT,都由你的 Quarkus 服务负责;Rovela 只负责投递。
本文面向 Quarkus(3.x / Java 17+,Kotlin 同样适用):用 @Path 做「发码 / 验码」资源,用类型化 REST Client(或 HttpClient)调 Rovela,HMAC 存内存或 Redis,并用 Idempotency-Key 避免超时重试发出两个不一致的码。这不是魔法链接教程——魔法链接请看 Next.js、NestJS、Rails、FastAPI。同系列 OTP:Spring Boot(最近的 JVM 兄弟篇)、Express.js、Django、Go、Actix / Rust。数字码 vs 链接:验证码还是链接。语言无关总览:用邮件发送 OTP。纯 Java/Kotlin 传输(非 OTP):Java 发信、Kotlin 发信——本文不复述 Jakarta Mail SMTP、MIME 拼装或「Hello World」发信。
你负责什么,Rovela 负责什么
Quarkus 应用负责:
SecureRandom→ 六位补零数字(绝不用Math.random()/java.util.Random)- 用服务端 pepper(
CODE_PEPPER)对email + ":" + code做 HMAC-SHA256(Mac.getInstance("HmacSHA256")) - 过期(文档示例约 10 分钟)、尝试次数、以及「同一地址一分钟内只能发一次」的重发门闸
- 按规范化邮箱 upsert,新发送会使旧码失效(
ConcurrentHashMap、Redis 或 SQL) - 核销时用恒定时间比较(
MessageDigest.isEqual);成功后删除 / 作废 - 对外响应是否暴露账号存在性(产品选择)保持一致
- 对
/auth/otp/send与/auth/otp/verify做限流
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(环境变量ROVELA_API_KEY——不要提交进仓库、不要放进前端包、不要打日志)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 只代表已接受并入队,不等于「已进收件箱」。
配置:application.properties + 环境变量
# application.properties — 生产环境用 env / secrets 注入
rovela.api-key=${ROVELA_API_KEY}
rovela.from=Acme <security@mail.acme.com>
rovela.base-url=https://api.rovela.dev
otp.code-pepper=${CODE_PEPPER}
otp.ttl-minutes=10
otp.resend-cooldown-seconds=60
otp.max-attempts=5
# MicroProfile REST Client(可选路径)
quarkus.rest-client.rovela.url=${rovela.base-url}
package com.example.otp;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import io.smallrye.config.WithName;
@ConfigMapping(prefix = "rovela")
public interface RovelaConfig {
@WithName("api-key")
String apiKey();
String from();
@WithName("base-url")
@WithDefault("https://api.rovela.dev")
String baseUrl();
}
package com.example.otp;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import io.smallrye.config.WithName;
@ConfigMapping(prefix = "otp")
public interface OtpConfig {
@WithName("code-pepper")
String codePepper();
@WithName("ttl-minutes")
@WithDefault("10")
int ttlMinutes();
@WithName("resend-cooldown-seconds")
@WithDefault("60")
int resendCooldownSeconds();
@WithName("max-attempts")
@WithDefault("5")
int maxAttempts();
}
像对待其他服务端密钥一样接入 ROVELA_API_KEY 与 CODE_PEPPER(见 Authentication、Quick Start)。生产 OTP 发送用 sending_access 就够了,也最合适。切勿把 sending_access 暴露给 SPA、移动端或 Quarkus Dev UI 截图。
优先 MicroProfile REST Client(或 Java HttpClient)
Spring Boot 兄弟篇用的是 RestClient / WebClient。在 Quarkus 里对应的惯用写法是类型化 MicroProfile REST Client;若想少引入扩展,Java 11+ HttpClient 也完全够用。
package com.example.otp;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@Path("/emails")
@RegisterRestClient(configKey = "rovela")
public interface RovelaMailClient {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
Map<?, ?> send(
@HeaderParam("Authorization") String authorization,
@HeaderParam("Idempotency-Key") String idempotencyKey,
Map<String, Object> body);
}
package com.example.otp;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Map;
import org.eclipse.microprofile.rest.client.inject.RestClient;
@ApplicationScoped
public class RovelaMailService {
private final RovelaMailClient client;
private final RovelaConfig cfg;
public RovelaMailService(@RestClient RovelaMailClient client, RovelaConfig cfg) {
this.client = client;
this.cfg = cfg;
}
public Map<?, ?> send(String to, String subject, String html, String text, String idempotencyKey) {
return client.send(
"Bearer " + cfg.apiKey(),
idempotencyKey,
Map.of(
"from", cfg.from(),
"to", to,
"subject", subject,
"html", html,
"text", text));
}
}
HttpClient 备选(无需 REST Client 扩展):
// 示意 — 相同请求头 + JSON body
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(cfg.baseUrl() + "/emails"))
.header("Authorization", "Bearer " + cfg.apiKey())
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofText());
两条路径调用的都是同一份 POST /emails 契约。OTP 场景优先 HTTP,而不是 Quarkus Mailer / Jakarta Mail SMTP:容器里 443 通常畅通,且 Idempotency-Key 是一等公民请求头。
落库哈希:SecureRandom + HMAC,而不是裸 SHA-256
六位数字只有一百万种可能。泄漏一张裸 SHA-256 表,一秒内就能撞出来。按文档来:用永远不离开服务器的 pepper 做 HMAC。
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 / 单 Quarkus 实例)。多副本或 native 镜像在负载均衡后面时,改用 Redis(ValueCommands)或 Postgres。
服务:发送 + 校验(ConcurrentHashMap 存储)
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);
}
}
package com.example.otp;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ApplicationScoped
public class OtpService {
private static final String SAFE_SEND_MSG =
"若该地址可收信,验证码已在路上。";
private final OtpConfig cfg;
private final RovelaMailService mail;
private final ConcurrentHashMap<String, OtpRecord> store = new ConcurrentHashMap<>();
public OtpService(OtpConfig cfg, RovelaMailService 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。
String idem = "otp-" + codeHash.substring(0, 32);
int ttlMin = cfg.ttlMinutes();
String html =
"<p>您的验证码是</p>"
+ "<p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">"
+ code
+ "</p>"
+ "<p>"
+ ttlMin
+ " 分钟内有效。如非本人操作,请忽略本邮件。</p>";
String text =
"您的验证码是 "
+ code
+ "。"
+ ttlMin
+ " 分钟内有效。如非本人操作,请忽略本邮件。";
mail.send(email, "您的 Acme 验证码:" + 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(SmallRye JWT、Quarkus OIDC 等)
return Map.of("ok", true);
}
}
把码放进主题方便锁屏预览——很多产品可以;共享屏幕场景可省略(见验证码文档)。务必同时发送 html 与 text,并把数字写进 text 部分(POST /emails)。可选:用控制台 模板(template.id + variables)代替内联 HTML——与 html/text 互斥,且 OTP 哈希仍由你的应用持有。
Redis:SET otp:{email} {json} 并设 TTL,外加 SET otp:cooldown:{email} 1 EX 60。Hibernate/SQL:按 email upsert,在事务里递增 attempts,成功后 DELETE。
资源与限流
package com.example.otp;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
@Path("/auth/otp")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OtpResource {
private final OtpService svc;
public OtpResource(OtpService svc) {
this.svc = svc;
}
public record SendBody(String email) {}
public record VerifyBody(String email, String code) {}
@POST
@Path("/send")
public Response send(SendBody body) {
if (body == null || body.email() == null || body.email().isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "invalid_email"))
.build();
}
try {
Map<String, Object> out = svc.send(body.email());
if (Boolean.FALSE.equals(out.get("ok"))) {
if ("too_soon".equals(out.get("error"))) {
return Response.status(429).entity(Map.of("error", "too_soon")).build();
}
}
return Response.ok(Map.of("message", out.get("message"))).build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_GATEWAY)
.entity(Map.of("error", "send_failed"))
.build();
}
}
@POST
@Path("/verify")
public Response verify(VerifyBody body) {
Map<String, Object> out = svc.verify(body.email(), body.code());
if (Boolean.FALSE.equals(out.get("ok"))) {
int status =
"locked".equals(out.get("error")) || "too_soon".equals(out.get("error")) ? 429 : 400;
return Response.status(status).entity(Map.of("error", out.get("error"))).build();
}
return Response.ok(Map.of("ok", true)).build();
}
}
为 /auth/otp/** 加 Bucket4j / Vert.x 限流(或前置 Cloudflare / nginx)做按 IP 限制。服务内按邮箱冷却与按 IP 限流解决不同问题——两者都要。加固清单见 Email OTP security。
发送失败按文档处理:400 recipient is suppressed: … → 换地址;域名未验证 → 告警自己;429 → 遵守 Retry-After 并保持同一 Idempotency-Key(验证码文档)。
Idempotency-Key:重试不会发双码
Worker、反向代理和 AI Agent 都会重试。没有 Idempotency-Key,一次超时就可能在用户收件箱里留下两个不一致的码。
- 请求头最长 256 字符;Rovela 记住 24 小时,重放返回原响应(API: emails)。
- 用码哈希派生(
otp-+ 前 32 位十六进制),让一次逻辑铸造对应一封入队邮件。 - 冷却后的主动重发会铸造新码 → 新哈希 → 新 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-quarkus-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "you@example.com",
"subject": "您的 Acme 验证码:482913",
"html": "<p>您的验证码是 <b>482913</b>。10 分钟内有效。</p>",
"text": "您的验证码是 482913。10 分钟内有效。"
}'
Quarkus vs Spring Boot 做 OTP(同一 API,不同接线)
| Quarkus(本文) | Spring Boot(兄弟篇) | |
|---|---|---|
| HTTP 表面 | JAX-RS @Path 资源 | @RestController |
| 配置 | @ConfigMapping / application.properties | @ConfigurationProperties |
| 出站 HTTP | MicroProfile REST Client 或 HttpClient | RestClient / WebClient |
| 依赖注入 | CDI @ApplicationScoped | Spring @Service / @Component |
| 密码学 / 存储 | 相同:SecureRandom + HMAC + map/Redis/SQL | 相同 |
| Rovela 角色 | 仅通过 POST /emails 投递 | 仅通过 POST /emails 投递 |
选你已经在跑的栈即可。不要在 Rovela 之上再发明一层 OTP 产品——两篇文章都是带 Idempotency-Key 调同一个发送接口。
为什么 OTP 不要用 Quarkus Mailer / Jakarta Mail SMTP
REST Client / HttpClient → POST /emails(本文) | Quarkus Mailer / Jakarta Mail | |
|---|---|---|
| 传输 | HTTPS 443 | 465 / 587(25 常被封) |
| 幂等 | Idempotency-Key 请求头 | 应用自行去重 |
| 反馈 | 消息 id + webhook | 稍后才有退信 DSN |
| 容器 / K8s / native | 443 通常畅通 | SMTP 常受限 |
| OTP 适配 | JSON html + text | 手写 MIME |
已有 SMTP 依赖时可以继续用。新建注册/登录数字码时,优先 HTTP API。更广的 Java 传输模式见 Sending email in Java。
认证数字码的投递注意(不是魔法链接)
OTP 邮件短且敏感。优先用已验证子域如 security@mail.example.com,对齐 SPF/DKIM/DMARC,HTML 保持克制。正文(以及常见主题)里的码通常不需要可点击链接——点击追踪改写对数字码影响小于魔法链接——但开信/点击追踪仍应落在你可控的域名上。
若进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。产品该用数字还是确认链接,见 code vs link。
Quarkus 里:数字 OTP vs 魔法链接
| 六位 OTP(本文) | 魔法链接 | |
|---|---|---|
| 用户动作 | 输入数字 | 点击 URL |
| 熵 | 低 → 必须 HMAC + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 重置链接模式 |
| Quarkus 表面 | 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)只在服务端 / Quarkus 配置——永不进前端。Idempotency-Key对同一逻辑发送保持稳定;新码 → 新 Key。- 若要隐藏账号存在性,对外文案保持一致。
- OTP 优先 HTTP API 而非 Quarkus Mailer SMTP;发信域已验证;进垃圾箱时走投递清单。
- 多副本:扩到多 Pod 前把内存 map 迁到 Redis/SQL。
延伸阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发送 OTP · Email OTP security。
- 体验选择:验证码还是链接。
- 投递:OTP / 魔法链接进垃圾箱。
- 框架兄弟篇:Spring Boot OTP · Express OTP · Django OTP · Go OTP · Actix OTP · 魔法链接 Next.js / NestJS / Rails / FastAPI。
- 纯 Java/Kotlin 传输:Java 发信 · Kotlin 发信。
一句话: 在 Quarkus REST 资源里铸造 SecureRandom 验证码,用 HMAC 落库(ConcurrentHashMap、Redis 或 SQL),对发送/校验限流,再用 MicroProfile REST Client 或 HttpClient 带 Idempotency-Key 调一次 Rovela——优先于 Quarkus Mailer SMTP;选 JVM 栈时可对照 Spring Boot 兄弟篇的接线差异;永远不要把邮件 API 当成你的认证服务器。