← 返回博客

Spring Boot 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Spring Boot 里做注册/登录邮箱验证码:SecureRandom、HMAC 落 ConcurrentHashMap/Redis/JPA、限流、用 RestClient 或 WebClient 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 Jakarta Mail;Rovela 只负责投递。

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

为什么用 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 443465 / 587(25 常被封)
幂等Idempotency-Key 头应用自己去重
反馈消息 id + webhook稍后 bounce DSN
容器 / K8s443 通常畅通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 + pepperURL token 熵高 → 通常 SHA-256 token 即可
文档形态验证码密码重置 / 链接模式
Spring 表面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)只放服务端。
  6. Idempotency-Key 对单次逻辑发送保持稳定;新码 → 新 key。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. OTP 优先 HTTP API 而非 Jakarta Mail SMTP;发信域已验证;进垃圾箱时用 spam 清单。

相关阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
  3. UX 选择:邮箱验证:验证码 vs 链接。
  4. 投递:为什么 OTP / 魔法链接进垃圾箱。
  5. 框架姊妹篇:Express OTP · Django OTP · Laravel OTP · Go OTP · 魔法链接 Next.js / NestJS / Rails / FastAPI。
  6. 仅 Java / Kotlin 发送:Java 发邮件 · Kotlin 发邮件。

**一句话:**在 Spring Boot 控制器里用 SecureRandom 发注册/登录 OTP,HMAC 落库(ConcurrentHashMap、Redis 或 JPA),限流发/验,再用带 Idempotency-Key 的一次 RestClient/WebClient POST 调 Rovela——优先于 Jakarta Mail SMTP,也永远不要把邮件 API 当成你的认证服务器。