Ktor / Kotlin 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Ktor 里做注册/登录邮箱验证码:SecureRandom、HMAC 落 ConcurrentHashMap/Redis/SQL、限流、用 HttpClient 调 POST /emails 并带 Idempotency-Key——优先 HTTPS 而非 SMTP;对照 Quarkus 与 Spring Boot 兄弟篇;Rovela 只负责投递。
为什么用 Ktor 发 OTP(而不是 Jakarta Mail)
Ktor 本身已经给注册/登录划好了边界:Application 模块里的 routing、类型化的 receive/respond、限流插件,以及服务端存储(ConcurrentHashMap、Redis 或 SQL)。你不需要 Rovela 的 Kotlin SDK,也不需要一个「OTP 产品」接口,更不需要把邮件 API 伪装成身份提供商。
你需要的是文档 邮箱验证码 里的配方:应用用 SecureRandom 生成六位码,只存 HMAC(从不存明文数字),设置过期与尝试次数,再用 Ktor HttpClient 带着 html 与 text 调用 POST /emails。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会创建会话。
Rovela 不提供 OTP 功能。 密码学、存储、重发节流、兑换逻辑、Session / JWT 都归你的 Ktor 服务;Rovela 只负责投递。
本文面向 Ktor Server(Ktor 2.x / 3.x + Kotlin 协程):「发码」「验码」两条路由、带 Idempotency-Key 的 HttpClient 客户端,以及内存或 Redis 的 HMAC 存储,避免超时重试投出两封互相矛盾的验证码。这不是魔术链接教程——魔术链接见 Next.js、NestJS、Rails、FastAPI。同系列 OTP:Spring Boot、Quarkus(最近的 JVM 兄弟篇)、Express.js、Go、Actix / Rust。数字码还是链接:验证码 vs 链接。语言无关总览:用邮件发 OTP。通用 Kotlin 传输(HttpURLConnection / OkHttp / Ktor client 入门、批量、Webhook)——不讲 OTP 所有权——见 用 Kotlin 发邮件;本文不重复欢迎信或签名校验流水账。
你负责什么,Rovela 负责什么
你的 Ktor 应用负责:
SecureRandom→ 六位补零(绝不用kotlin.random.Random/Math.random()生成秘密)- 对
email + ":" + code做 HMAC-SHA256,pepper 为服务端CODE_PEPPER(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——勿提交、勿进浏览器/WASM、勿打日志)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 只表示已接受并入队,不等于「已进收件箱」。
配置:环境变量 / application.conf(密钥永不进客户端)
# application.conf — 生产用环境变量覆盖密钥
rovela {
apiKey = ${?ROVELA_API_KEY}
from = "Acme <security@mail.acme.com>"
baseUrl = "https://api.rovela.dev"
}
otp {
codePepper = ${?CODE_PEPPER}
ttlMinutes = 10
resendCooldownSeconds = 60
maxAttempts = 5
}
data class RovelaConfig(
val apiKey: String,
val from: String,
val baseUrl: String = "https://api.rovela.dev",
)
data class OtpConfig(
val codePepper: String,
val ttlMinutes: Int = 10,
val resendCooldownSeconds: Int = 60,
val maxAttempts: Int = 5,
)
fun Application.loadRovelaConfig(): RovelaConfig {
val cfg = environment.config
return RovelaConfig(
apiKey = cfg.property("rovela.apiKey").getString(),
from = cfg.property("rovela.from").getString(),
baseUrl = cfg.propertyOrNull("rovela.baseUrl")?.getString()
?: "https://api.rovela.dev",
)
}
fun Application.loadOtpConfig(): OtpConfig {
val cfg = environment.config
return OtpConfig(
codePepper = cfg.property("otp.codePepper").getString(),
ttlMinutes = cfg.propertyOrNull("otp.ttlMinutes")?.getString()?.toInt() ?: 10,
resendCooldownSeconds =
cfg.propertyOrNull("otp.resendCooldownSeconds")?.getString()?.toInt() ?: 60,
maxAttempts = cfg.propertyOrNull("otp.maxAttempts")?.getString()?.toInt() ?: 5,
)
}
像对待其他服务端密钥一样配置 ROVELA_API_KEY 与 CODE_PEPPER(Authentication、Quick Start)。生产 OTP 发送方用 sending_access 就够了——也是推荐范围。切勿把 sending_access 暴露给 SPA、移动端或共享 CI 日志。
优先用 Ktor HttpClient(加 Idempotency-Key)
Spring Boot 兄弟篇用 RestClient / WebClient;Quarkus 用 MicroProfile REST Client 或 Java HttpClient。在 Ktor 里对等物是协程 HttpClient(CIO 或 OkHttp 引擎)+ ContentNegotiation。
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class RovelaEmailBody(
val from: String,
val to: String,
val subject: String,
val html: String,
val text: String,
)
class RovelaMailClient(
private val cfg: RovelaConfig,
private val http: HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
},
) {
suspend fun send(
to: String,
subject: String,
html: String,
text: String,
idempotencyKey: String,
): String {
val response = http.post("${cfg.baseUrl}/emails") {
header(HttpHeaders.Authorization, "Bearer ${cfg.apiKey}")
header("Idempotency-Key", idempotencyKey)
header(HttpHeaders.Accept, "application/json")
contentType(ContentType.Application.Json)
setBody(
RovelaEmailBody(
from = cfg.from,
to = to,
subject = subject,
html = html,
text = text,
),
)
}
val body = response.bodyAsText()
check(response.status.isSuccess()) {
"Rovela ${response.status.value}: $body"
}
return body // {"object":"email","id":"..."}
}
}
这与其他框架兄弟篇调用同一份 POST /emails 合约。OTP 优先走 HTTPS 而不是裸 SMTP / Jakarta Mail:容器里 443 通常畅通,且 Idempotency-Key 是一等公民。
落库哈希:SecureRandom + HMAC,而不是裸 SHA-256
六位码只有一百万种可能。泄露一张裸 SHA-256 表,一秒内就能反推。按文档做:用永不离开服务器的 pepper 做 HMAC。
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
object OtpCrypto {
private val rng = SecureRandom()
fun normalizeEmail(raw: String?): String =
raw?.trim()?.lowercase().orEmpty()
fun mintCode(): String {
// CSPRNG — kotlin.random.Random / Math.random() 不适合秘密
val n = rng.nextInt(1_000_000)
return "%06d".format(n)
}
fun hashCode(pepper: String, email: String, code: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(pepper.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
val out = mac.doFinal("$email:$code".toByteArray(StandardCharsets.UTF_8))
return HexFormat.of().formatHex(out)
}
fun safeEqualHex(a: String, b: String): Boolean {
val ab = HexFormat.of().parseHex(a)
val 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(单实例)。多副本负载均衡前请换 Redis 或 Postgres。
服务:发送 + 校验(ConcurrentHashMap 存储)
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
data class OtpRecord(
val codeHash: String,
val attempts: Int,
val expiresAt: Instant,
val sentAt: Instant,
)
class OtpService(
private val cfg: OtpConfig,
private val mail: RovelaMailClient,
) {
private val store = ConcurrentHashMap<String, OtpRecord>()
private val safeSendMsg = "If that address can receive mail, a code is on its way."
suspend fun send(rawEmail: String): Map<String, Any?> {
val email = OtpCrypto.normalizeEmail(rawEmail)
val now = Instant.now()
val existing = store[email]
if (existing != null &&
now.isBefore(existing.sentAt.plusSeconds(cfg.resendCooldownSeconds.toLong()))
) {
return mapOf("ok" to false, "error" to "too_soon")
}
val code = OtpCrypto.mintCode()
val codeHash = OtpCrypto.hashCode(cfg.codePepper, email, code)
store[email] = OtpRecord(
codeHash = codeHash,
attempts = 0,
expiresAt = now.plusSeconds(cfg.ttlMinutes * 60L),
sentAt = now,
)
// 每次逻辑发送稳定:哈希前缀。新码 ⇒ 新 key。
val idem = "otp-" + codeHash.take(32)
val ttlMin = cfg.ttlMinutes
val 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>
""".trimIndent()
val 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 mapOf("ok" to true, "message" to safeSendMsg)
}
fun verify(rawEmail: String, rawCode: String?): Map<String, Any?> {
val email = OtpCrypto.normalizeEmail(rawEmail)
val code = rawCode?.trim().orEmpty()
if (code.length != 6 || !code.all { it.isDigit() }) {
return mapOf("ok" to false, "error" to "invalid")
}
val rec = store[email] ?: return mapOf("ok" to false, "error" to "invalid")
val now = Instant.now()
if (now.isAfter(rec.expiresAt)) {
store.remove(email)
return mapOf("ok" to false, "error" to "expired")
}
if (rec.attempts >= cfg.maxAttempts) {
return mapOf("ok" to false, "error" to "locked")
}
val actual = OtpCrypto.hashCode(cfg.codePepper, email, code)
if (!OtpCrypto.safeEqualHex(actual, rec.codeHash)) {
store[email] = rec.copy(attempts = rec.attempts + 1)
return mapOf("ok" to false, "error" to "invalid")
}
store.remove(email)
// 在此签发 session / JWT(ktor-server-auth-jwt 等)
return mapOf("ok" to true)
}
}
把验证码放进主题便于锁屏预览——许多产品可以;高度共享的屏幕可省略(验证码文档)。务必同时发送 html 与 text,并在文本部分包含数字(POST /emails)。可选:用控制台 模板(template.id + 变量)代替内联 HTML——仍与 html/text 互斥,OTP 哈希依旧由你的应用保管。
Redis:SET otp:{email} {json} 并设 TTL,另加 SET otp:cooldown:{email} 1 EX 60。SQL:按邮箱 upsert,事务内递增 attempts,成功后 DELETE。
Application 模块里的路由 + 限流
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
@Serializable
data class SendBody(val email: String)
@Serializable
data class VerifyBody(val email: String, val code: String)
fun Application.module() {
val rovelaCfg = loadRovelaConfig()
val otpCfg = loadOtpConfig()
val mail = RovelaMailClient(rovelaCfg)
val otp = OtpService(otpCfg, mail)
install(ContentNegotiation) { json() }
install(StatusPages) {
exception<IllegalStateException> { call, _ ->
call.respond(HttpStatusCode.BadGateway, mapOf("error" to "send_failed"))
}
}
// 在 /auth/otp/** 上再加 RateLimit / 自定义插件 / 反向代理限流
routing {
route("/auth/otp") {
post("/send") {
val body = call.receive<SendBody>()
if (body.email.isBlank()) {
call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid_email"))
return@post
}
val out = otp.send(body.email)
if (out["ok"] == false && out["error"] == "too_soon") {
call.respond(HttpStatusCode.TooManyRequests, mapOf("error" to "too_soon"))
return@post
}
call.respond(mapOf("message" to out["message"]))
}
post("/verify") {
val body = call.receive<VerifyBody>()
val out = otp.verify(body.email, body.code)
if (out["ok"] == false) {
val status =
if (out["error"] == "locked" || out["error"] == "too_soon") {
HttpStatusCode.TooManyRequests
} else {
HttpStatusCode.BadRequest
}
call.respond(status, mapOf("error" to out["error"]))
return@post
}
call.respond(mapOf("ok" to true))
}
}
}
}
服务内按邮箱冷却,与边缘按 IP 限流解决的是不同问题——两者都要。加固清单:Email OTP security。
发送失败按文档处理:400 recipient is suppressed: … → 换地址;域名未验证 → 告警自己;429 → 遵守 Retry-After 并保持同一 Idempotency-Key(验证码文档)。
Idempotency-Key:重试不双发
Worker、反向代理、AI Agent 都会重试。没有 Idempotency-Key,一次超时就可能往收件箱投两封互相矛盾的验证码。
- Header ≤ 256 字符;Rovela 记 24 小时,重放返回原响应(API: emails)。
- 从 code hash 派生(
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-ktor-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."
}'
Ktor vs Quarkus vs Spring Boot(同一 API,不同接线)
| Ktor(本文) | Quarkus(兄弟篇) | Spring Boot(兄弟篇) | |
|---|---|---|---|
| HTTP 面 | Application.module() 里 routing { post(...) } | JAX-RS @Path | @RestController |
| 配置 | HOCON / 环境变量 | @ConfigMapping / application.properties | @ConfigurationProperties |
| 出站 HTTP | Ktor HttpClient | MicroProfile REST Client 或 HttpClient | RestClient / WebClient |
| 并发模型 | 协程 / suspend | CDI + Vert.x / 阻塞 | Spring MVC 或 WebFlux |
| 密码学 / 存储 | 相同:SecureRandom + HMAC + map/Redis/SQL | 相同 | 相同 |
| Rovela 角色 | 仅通过 POST /emails 投递 | 仅投递 | 仅投递 |
选你已经在跑的栈。不要在 Rovela 之上再发明一层 OTP 产品——三篇文章都调用同一个带 Idempotency-Key 的发送端点。
为什么 OTP 不用 SMTP / Jakarta Mail
Ktor HttpClient → POST /emails(本文) | SMTP / Jakarta Mail | |
|---|---|---|
| 传输 | HTTPS 443 | 465 / 587(25 常被封) |
| 幂等 | Idempotency-Key | 应用自行去重 |
| 反馈 | 消息 id + Webhook | 稍后 bounce DSN |
| 容器 / K8s | 443 通常可用 | SMTP 常受限 |
| OTP 适配 | JSON html + text | 手搓 MIME |
已有 SMTP 依赖可以继续用。新的注册/登录数字码,优先 HTTP API。更广的 Kotlin 传输模式见 用 Kotlin 发邮件。
认证数字码的投递注意(不是魔术 URL)
OTP 邮件短且敏感。优先用已验证子域(如 security@mail.example.com),对齐 SPF/DKIM/DMARC,HTML 尽量克制。正文(以及常见的主题)里的数字不需要可点链接——因此点击追踪改写对魔术链接更关键——但打开/点击追踪仍应落在你控制的域名上。
若进垃圾箱,按 OTP 与魔术链接进垃圾箱 排查。产品该用数字还是确认链接,见 验证码 vs 链接。
Ktor 里:OTP 数字码 vs 魔术链接
| 六位 OTP(本文) | 魔术链接 | |
|---|---|---|
| 用户动作 | 输入数字 | 点击 URL |
| 秘密熵 | 低 → 必须 HMAC + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形状 | 验证码 | 密码重置 / 链接模式 |
| Ktor 表面 | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 次要 | 安全发信域上建议关闭 |
两者都调同一个 POST /emails。分开存——不要把 OTP 记录当成魔术链接 token。
上线前清单
SecureRandom+ HMAC pepper;永不存明文;OTP 绝不用非加密 PRNG。- 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
- upsert / 覆盖使旧码失效;校验成功后删除。
- 同时发
html与text(或模板);主题可含码便于预览。 ROVELA_API_KEY(sending_access)只在服务端 / HOCON 环境——绝不进前端。Idempotency-Key按逻辑发送稳定;新码 → 新 key。- 若隐藏账号存在性,对外话术一致。
- OTP 优先 Ktor
HttpClient而非 SMTP;发信域已验证;进垃圾箱时对照清单。 - 多副本:扩到多 pod 前把 map 迁到 Redis/SQL。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · Email OTP security。
- UX 选择:验证码 vs 链接。
- 投递:OTP 与魔术链接进垃圾箱。
- 框架兄弟篇:Spring Boot OTP · Quarkus OTP · Express OTP · Go OTP · Actix OTP · 魔术链接 Next.js / NestJS / Rails / FastAPI。
- 仅 Kotlin 传输:用 Kotlin 发邮件。
一句话: 把注册/登录 OTP 放进 Ktor 的 Application 模块:用 SecureRandom 铸码、HMAC 落库(ConcurrentHashMap / Redis / SQL)、限流发码与验码,再以带 Idempotency-Key 的一次 HttpClient POST 交给 Rovela——优先于 SMTP,在选 JVM 栈时对照 Quarkus / Spring Boot 兄弟篇的接线,且永远不要把邮件 API 当成你的认证服务器。