← 返回博客

Ktor / Kotlin 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Ktor 里做注册/登录邮箱验证码:SecureRandom、HMAC 落 ConcurrentHashMap/Redis/SQL、限流、用 HttpClient 调 POST /emails 并带 Idempotency-Key——优先 HTTPS 而非 SMTP;对照 Quarkus 与 Spring Boot 兄弟篇;Rovela 只负责投递。

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

为什么用 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
出站 HTTPKtor HttpClientMicroProfile REST Client 或 HttpClientRestClient / WebClient
并发模型协程 / suspendCDI + 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 443465 / 587(25 常被封)
幂等Idempotency-Key应用自行去重
反馈消息 id + Webhook稍后 bounce DSN
容器 / K8s443 通常可用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 + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形状验证码密码重置 / 链接模式
Ktor 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪次要安全发信域上建议关闭

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

上线前清单

  1. SecureRandom + HMAC pepper;永不存明文;OTP 绝不用非加密 PRNG。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
  3. upsert / 覆盖使旧码失效;校验成功后删除。
  4. 同时发 html 与 text(或模板);主题可含码便于预览。
  5. ROVELA_API_KEY(sending_access)只在服务端 / HOCON 环境——绝不进前端。
  6. Idempotency-Key 按逻辑发送稳定;新码 → 新 key。
  7. 若隐藏账号存在性,对外话术一致。
  8. OTP 优先 Ktor HttpClient 而非 SMTP;发信域已验证;进垃圾箱时对照清单。
  9. 多副本:扩到多 pod 前把 map 迁到 Redis/SQL。

相关阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发 OTP · Email OTP security。
  3. UX 选择:验证码 vs 链接。
  4. 投递:OTP 与魔术链接进垃圾箱。
  5. 框架兄弟篇:Spring Boot OTP · Quarkus OTP · Express OTP · Go OTP · Actix OTP · 魔术链接 Next.js / NestJS / Rails / FastAPI。
  6. 仅 Kotlin 传输:用 Kotlin 发邮件。

一句话: 把注册/登录 OTP 放进 Ktor 的 Application 模块:用 SecureRandom 铸码、HMAC 落库(ConcurrentHashMap / Redis / SQL)、限流发码与验码,再以带 Idempotency-Key 的一次 HttpClient POST 交给 Rovela——优先于 SMTP,在选 JVM 栈时对照 Quarkus / Spring Boot 兄弟篇的接线,且永远不要把邮件 API 当成你的认证服务器。