Laravel 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Laravel 里做注册/登录邮箱验证码:random_int、HMAC 落 Cache 或 Eloquent、限流、用 Http:: 或 Guzzle 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 Mail:: SMTP;Rovela 只负责投递。
为什么用 Laravel + 邮箱验证码(而不是 Mail:: SMTP 或魔法链接 SDK)
Laravel 本来就适合做注册/登录:路由、Form Request、中间件(throttle)、Cache 或 Eloquent、以及队列。你不需要 Rovela 的 Laravel 包、「OTP 产品」接口,或把邮件 API 当成身份提供商的自定义 Guard。
你需要的是文档里的配方——邮箱验证码:应用用 random_int 生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再用 Laravel 的 Http::(或 Guzzle)调 POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。
Rovela 不提供 OTP 功能。 Laravel 负责密码学、缓存键或数据行、重发节流、核销逻辑以及 session / Sanctum token。Rovela 只负责投递。
OTP 优先走 HTTP API,而不是 Mail:: + SMTP。云主机常限制出站 25/465/587;HTTPS 443 通常畅通。你还能拿到 message id、webhook 事件,以及 Idempotency-Key,避免超时重试入队两封不一致的码。遗留 Mailable 路径可以继续用 SMTP——新的认证数字码应调用 POST /emails。
本文面向 Laravel / PHP(11.x 友好):「发码」与「验码」控制器、薄封装的 Rovela 客户端、Cache 或 Eloquent 存储,以及限流。这不是魔法链接教程——魔法链接见 Rails、Next.js、NestJS、FastAPI。同款 OTP 姊妹篇:Express.js、Django。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。通用 PHP/Laravel 发送(非 OTP)见 PHP 与 Laravel 发邮件——本文不重复 Mailable、SMTP DSN 或「hello world」发送。
你负责什么,Rovela 发什么
Laravel 应用负责:
random_int(0, 999_999)→ 六位、sprintf左侧补零(不要用rand()/mt_rand())- 用
hash_hmac对email + ":" + code做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
- 按规范化邮箱 upsert,新发会使旧码失效(Cache 键或 Eloquent 行)
- 核销时用
hash_equals常量时间比较;成功后 forget / delete - 是否暴露账号存在性由产品决定,但公开响应宜保持一致
- 对发码/验码路由做限流(
throttle中间件和/或RateLimiter)
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(.env里的ROVELA_API_KEY——勿提交、勿放进前端 Vite 环境变量、勿打印到日志)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
配置与薄封装客户端(Http::)
# .env 示意
ROVELA_API_KEY=re_...
CODE_PEPPER=long-random-secret
OTP_FROM="Acme <security@mail.acme.com>"
OTP_TTL_MINUTES=10
OTP_RESEND_COOLDOWN_SEC=60
OTP_MAX_ATTEMPTS=5
<?php
// app/Services/RovelaMail.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class RovelaMail
{
public function send(
string $to,
string $subject,
string $html,
string $text,
string $idempotencyKey,
): array {
$apiKey = config('services.rovela.key'); // 映射 ROVELA_API_KEY
$from = config('services.rovela.from');
$response = Http::withToken($apiKey)
->withHeaders([
'Idempotency-Key' => $idempotencyKey,
'Accept' => 'application/json',
])
->timeout(15)
->post('https://api.rovela.dev/emails', [
'from' => $from,
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
]);
if ($response->failed()) {
throw new RuntimeException(
'Rovela send failed: '.$response->status().' '.$response->body()
);
}
return $response->json();
}
}
<?php
// config/services.php(片段)
'rovela' => [
'key' => env('ROVELA_API_KEY'),
'from' => env('OTP_FROM', 'Acme <security@mail.acme.com>'),
],
Http::withToken 会设置 Authorization: Bearer …。在 Laravel 容器外可用 Guzzle 发同样的 JSON。密钥按普通服务端凭证接入(Authentication、Quick Start)。
落库哈希:random_int + HMAC,不要裸 SHA-1
六位码只有一百万种可能。泄露的裸哈希表几秒内就能反推。按文档:用永不离开服务器的 pepper 做 HMAC。
<?php
// app/Services/OtpCrypto.php
namespace App\Services;
class OtpCrypto
{
public static function normalizeEmail(string $raw): string
{
return strtolower(trim($raw));
}
public static function mintCode(): string
{
// CSPRNG — rand() / mt_rand() 不适合密钥材料
return sprintf('%06d', random_int(0, 999_999));
}
public static function hashCode(string $email, string $code): string
{
$pepper = (string) env('CODE_PEPPER');
return hash_hmac('sha256', $email.':'.$code, $pepper);
}
public static function safeEqual(string $a, string $b): bool
{
return hash_equals($a, $b);
}
}
建议的表结构(每个邮箱一条有效码),对齐 验证码示例:
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()
);
Eloquent 示意:
<?php
// app/Models/VerificationCode.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VerificationCode extends Model
{
protected $primaryKey = 'email';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
'email', 'code_hash', 'expires_at', 'attempts', 'sent_at',
];
protected $casts = [
'expires_at' => 'datetime',
'sent_at' => 'datetime',
'attempts' => 'integer',
];
}
缓存替代方案: 在 otp:{email} 下存 {code_hash, attempts, sent_at},TTL = 过期时间(Cache::put)。重发冷却可用第二个键 otp:cooldown:{email}。短命验证码用 Redis/缓存即可;需要可审计或多 worker 且无共享 Redis 时用数据库。
服务:发码 + 验码(Cache 版)
<?php
// app/Services/OtpService.php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Carbon;
class OtpService
{
public function __construct(private RovelaMail $mail) {}
public function send(string $rawEmail): array
{
$email = OtpCrypto::normalizeEmail($rawEmail);
$ttlMin = (int) env('OTP_TTL_MINUTES', 10);
$cooldown = (int) env('OTP_RESEND_COOLDOWN_SEC', 60);
$key = 'otp:'.$email;
$coolKey = 'otp:cooldown:'.$email;
if (Cache::has($coolKey)) {
return ['ok' => false, 'error' => 'too_soon'];
}
$code = OtpCrypto::mintCode();
$codeHash = OtpCrypto::hashCode($email, $code);
$now = Carbon::now();
Cache::put($key, [
'code_hash' => $codeHash,
'attempts' => 0,
'sent_at' => $now->toIso8601String(),
], now()->addMinutes($ttlMin));
Cache::put($coolKey, 1, now()->addSeconds($cooldown));
// 同一逻辑发送用稳定键:哈希前缀。新码 ⇒ 新键。
$idempotencyKey = 'otp-'.substr($codeHash, 0, 32);
$this->mail->send(
to: $email,
subject: "Your Acme code: {$code}",
html: '<p>Your verification code is</p>'
.'<p style="font-size:28px;font-weight:600;letter-spacing:4px">'
.e($code)
.'</p>'
."<p>It expires in {$ttlMin} minutes. "
.'If you did not ask for it, ignore this email.</p>',
text: "Your verification code is {$code}. "
."It expires in {$ttlMin} minutes. "
.'If you did not ask for it, ignore this email.',
idempotencyKey: $idempotencyKey,
);
return [
'ok' => true,
'message' => '若该地址可收信,验证码已在路上。',
];
}
public function verify(string $rawEmail, string $rawCode): array
{
$email = OtpCrypto::normalizeEmail($rawEmail);
$code = trim($rawCode);
$maxAttempts = (int) env('OTP_MAX_ATTEMPTS', 5);
$key = 'otp:'.$email;
if (! (strlen($code) === 6 && ctype_digit($code))) {
return ['ok' => false, 'error' => 'invalid'];
}
$row = Cache::get($key);
if (! is_array($row) || empty($row['code_hash'])) {
return ['ok' => false, 'error' => 'invalid'];
}
if ((int) ($row['attempts'] ?? 0) >= $maxAttempts) {
return ['ok' => false, 'error' => 'locked'];
}
$match = OtpCrypto::safeEqual(
OtpCrypto::hashCode($email, $code),
(string) $row['code_hash'],
);
if (! $match) {
$row['attempts'] = (int) ($row['attempts'] ?? 0) + 1;
Cache::put($key, $row, now()->addMinutes((int) env('OTP_TTL_MINUTES', 10)));
return ['ok' => false, 'error' => 'invalid'];
}
Cache::forget($key);
// 在此签发 session / Sanctum token
return ['ok' => true];
}
}
把码放进 subject 有利于锁屏预览——多数产品可接受,共享大屏场景可省略(验证码文档)。务必同时发送 html 与 text,并在 text 中带上数字(POST /emails)。
若走 Eloquent:按 email 做 updateOrCreate,在事务里 lockForUpdate() 递增 attempts,成功后 delete()——密码学与 Rovela 客户端同上。
控制器 + 限流
<?php
// app/Http/Controllers/OtpController.php
namespace App\Http\Controllers;
use App\Services\OtpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OtpController extends Controller
{
public function send(Request $request, OtpService $otp): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email:rfc'],
]);
$result = $otp->send($data['email']);
if (! ($result['ok'] ?? false) && ($result['error'] ?? null) === 'too_soon') {
return response()->json(['error' => 'too_soon'], 429);
}
return response()->json([
'message' => $result['message'] ?? 'ok',
]);
}
public function verify(Request $request, OtpService $otp): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email:rfc'],
'code' => ['required', 'string'],
]);
$result = $otp->verify($data['email'], $data['code']);
if (! ($result['ok'] ?? false)) {
$err = $result['error'] ?? 'invalid';
$status = in_array($err, ['locked', 'too_soon'], true) ? 429 : 400;
return response()->json(['error' => $err], $status);
}
// Auth::login(...) 或签发 Sanctum token
return response()->json(['ok' => true]);
}
}
<?php
// routes/api.php(片段)
use App\Http\Controllers\OtpController;
Route::post('/auth/otp/send', [OtpController::class, 'send'])
->middleware('throttle:10,1');
Route::post('/auth/otp/verify', [OtpController::class, 'verify'])
->middleware('throttle:20,1');
服务内的按邮箱冷却,与按 IP 的 throttle / RateLimiter 解决不同问题——两者都要。加固清单见 邮箱 OTP 安全。
Idempotency-Key:重试不会发两封码
队列 worker、HTTP 客户端重试、Octane worker,以及 AI agent 都会重试。没有 Idempotency-Key,一次超时就可能让收件箱出现两封不一致的码。
- Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
- 由 code hash 派生(
otp-+ 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。 - 冷却后的主动重发会铸造新码 → 新哈希 → 新键。不要用旧键发新码。
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-laravel-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."
}'
为什么 OTP 不用 Mail:: / SMTP
Http:: → POST /emails(本文) | Mail:: + SMTP | |
|---|---|---|
| 传输 | HTTPS 443 | 465 / 587(25 常被拦) |
| 幂等 | Idempotency-Key | 应用自己去重 |
| 反馈 | message id + webhook | 之后才有退信 DSN |
| Serverless / PaaS | 443 通常开放 | SMTP 常受限 |
| OTP 适配 | JSON html + text | 经 Mailable 拼 MIME |
已经依赖 SMTP 时可以继续用 Mailable。新的注册/登录数字码请调 HTTP API。更广的 PHP 发送模式(cURL、HTTP Client、webhook 验签)见 PHP 与 Laravel 发邮件。
认证数字码的投递注意(不是魔法 URL)
OTP 邮件短且敏感。优先用已验证子域,例如 security@mail.example.com,对齐 SPF/DKIM/DMARC,并保持 HTML 简洁。正文(以及常见的 subject)里的码不需要可点链接——点击追踪改写对魔法链接更关键——但打开/点击追踪仍应落在你控制的域名上。
若进垃圾箱,按 为什么 OTP / 魔法链接进垃圾箱 排查。产品该用数字码还是确认链接,见 验证码 vs 链接。
Laravel 里:验证码 vs 魔法链接
| 六位 OTP(本文) | 魔法链接 | |
|---|---|---|
| 用户动作 | 输入数字 | 点击 URL |
| 秘密熵 | 低 → 必须 HMAC + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Laravel 表面 | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 可选关注 | 安全发信域上建议关闭 |
两者都调同一个 POST /emails。模型/服务分开——不要把 OTP 缓存项当成魔法链接 token 复用。Rails 魔法链接见 Rails 魔法登录。
上线前清单
random_int+ HMAC pepper;永不存明文码;不用rand()/mt_rand()。- 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP
throttle。 - upsert / 覆盖会使旧码失效;验码成功后 forget/delete。
- 同时有
html和text;可选把码放进 subject 方便预览。 ROVELA_API_KEY(sending_access)只放服务端(不要VITE_*)。Idempotency-Key对同一次逻辑发送保持稳定;新码 → 新键。- 若隐藏账号存在性,公开文案保持一致。
- OTP 优先
Http::而非 SMTP;发信域已验证;进垃圾箱则按清单排查。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
- UX 选择:邮箱验证:验证码 vs 链接。
- 投递:为什么 OTP / 魔法链接进垃圾箱。
- 框架姊妹篇:Express OTP · Django OTP · 魔法链接 Rails / Next.js / NestJS / FastAPI。
- 仅 PHP 发送:PHP 与 Laravel 发邮件。
一句话: 把注册/登录 OTP 放进 Laravel 控制器——用 random_int 铸码、HMAC 落库(Cache 或 Eloquent)、限流发码/验码,再带 Idempotency-Key 调一次 Http:: POST 给 Rovela;优先于 Mail:: SMTP,也永远不要把邮件 API 当成认证服务器。