← 返回博客

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 只负责投递。

更新于 2026年11月4日
Quarkus 发送邮箱验证码(2026):用 HTTP API 发 OTP

为什么用 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
出站 HTTPMicroProfile REST Client 或 HttpClientRestClient / WebClient
依赖注入CDI @ApplicationScopedSpring @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 443465 / 587(25 常被封)
幂等Idempotency-Key 请求头应用自行去重
反馈消息 id + webhook稍后才有退信 DSN
容器 / K8s / native443 通常畅通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 + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形态验证码重置链接模式
Quarkus 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪可选关注安全发信域上建议关闭

两者都调同一个 POST /emails。分开存储——不要把 OTP 记录复用成魔法链接 token。

上线前清单

  1. SecureRandom + HMAC pepper;永不存明文码;OTP 绝不用 Math.random()。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
  3. Upsert / 覆盖使旧码失效;校验成功后删除。
  4. 同时发 html 与 text(或模板);主题里可选带码方便预览。
  5. ROVELA_API_KEY(sending_access)只在服务端 / Quarkus 配置——永不进前端。
  6. Idempotency-Key 对同一逻辑发送保持稳定;新码 → 新 Key。
  7. 若要隐藏账号存在性,对外文案保持一致。
  8. OTP 优先 HTTP API 而非 Quarkus Mailer SMTP;发信域已验证;进垃圾箱时走投递清单。
  9. 多副本:扩到多 Pod 前把内存 map 迁到 Redis/SQL。

延伸阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发送 OTP · Email OTP security。
  3. 体验选择:验证码还是链接。
  4. 投递:OTP / 魔法链接进垃圾箱。
  5. 框架兄弟篇:Spring Boot OTP · Express OTP · Django OTP · Go OTP · Actix OTP · 魔法链接 Next.js / NestJS / Rails / FastAPI。
  6. 纯 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 当成你的认证服务器。