← 返回博客

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

在 Laravel 里做注册/登录邮箱验证码:random_int、HMAC 落 Cache 或 Eloquent、限流、用 Http:: 或 Guzzle 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 Mail:: SMTP;Rovela 只负责投递。

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

为什么用 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 443465 / 587(25 常被拦)
幂等Idempotency-Key应用自己去重
反馈message id + webhook之后才有退信 DSN
Serverless / PaaS443 通常开放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 + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形态验证码密码重置 / 链接模式
Laravel 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪可选关注安全发信域上建议关闭

两者都调同一个 POST /emails。模型/服务分开——不要把 OTP 缓存项当成魔法链接 token 复用。Rails 魔法链接见 Rails 魔法登录。

上线前清单

  1. random_int + HMAC pepper;永不存明文码;不用 rand() / mt_rand()。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP throttle。
  3. upsert / 覆盖会使旧码失效;验码成功后 forget/delete。
  4. 同时有 html 和 text;可选把码放进 subject 方便预览。
  5. ROVELA_API_KEY(sending_access)只放服务端(不要 VITE_*)。
  6. Idempotency-Key 对同一次逻辑发送保持稳定;新码 → 新键。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. OTP 优先 Http:: 而非 SMTP;发信域已验证;进垃圾箱则按清单排查。

相关阅读

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

一句话: 把注册/登录 OTP 放进 Laravel 控制器——用 random_int 铸码、HMAC 落库(Cache 或 Eloquent)、限流发码/验码,再带 Idempotency-Key 调一次 Http:: POST 给 Rovela;优先于 Mail:: SMTP,也永远不要把邮件 API 当成认证服务器。