Flutter 发送邮箱验证码(2026):后端 HTTP 调事务邮件 API
用 Dart shelf 或云函数 BFF 为 Flutter 做注册/登录邮箱验证码:Random.secure + HMAC,带 Idempotency-Key 调 POST /emails——发送密钥永不进移动端;Rovela 只负责投递。
为什么 Flutter 发邮箱验证码必须走后端
Flutter 很适合做注册/登录的 UI——邮箱输入框、焦点、六位数字码在 iOS/Android 上都很顺手。但它不适合存放 Rovela 的 sending_access 密钥。凡是打进 APK/IPA(或设备上能出网的 Dart isolate)的内容都可能被提取。把 ROVELA_API_KEY 塞进 --dart-define、.env 资源或 Firebase Remote Config,密钥仍然落在每台手机上。
你需要的是文档里的配方——邮箱验证码:由 服务端(Dart shelf、Cloud Functions、Cloud Run 或任意 BFF)用 CSPRNG 生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再 POST https://api.rovela.dev/emails,同时带上 html、text 和稳定的 Idempotency-Key。Flutter 应用只通过 HTTPS 调用你自己的 /auth/otp/send 与 /auth/otp/verify。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。
Rovela 不提供 OTP 功能。 后端负责密码学、存储条目、重发节流、核销逻辑以及 session / JWT。Rovela 只负责投递。
本文面向 Flutter + Dart:一个用 shelf(或 Cloud Function)对接 Rovela 的 OTP API,以及永远看不到发送密钥的精简 Flutter 客户端。这不是 用 Dart 发邮件(通用 Dart/http 发送,没有 OTP 存储),也不是 Web/Node 的 OTP 姊妹篇(Express、Hono、Go)。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。
你负责什么,Rovela 发什么
服务端负责:
- CSPRNG → 六位、左侧补零(Dart 用
Random.secure()——不要用Random()) - 用
package:crypto对email + ":" + code做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER) - 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
- 按规范化邮箱 upsert,新发会使旧码失效(内存 map、Redis 或 SQL)
- 核销时常量时间比较;成功后 delete / 失效
- 对
/auth/otp/send与/auth/otp/verify做限流 - 验码成功后签发 session cookie / JWT
Flutter 应用负责:
- 收集邮箱与验证码、调用你的 BFF、安全存储会话(
flutter_secure_storage或系统钥匙串) - UX:重发冷却计时、错误文案、离线处理
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(仅服务端环境变量——勿打进 Flutter 包,勿用会随商店包分发的--dart-define携带)- 入队、签名、重试、退信/投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
架构:手机 → 你的 API → Rovela
Flutter(UI)
│ HTTPS JSON(没有 Rovela 密钥)
▼
Dart shelf / Cloud Function / Cloud Run(ROVELA_API_KEY + CODE_PEPPER)
│ POST /emails + Idempotency-Key
▼
api.rovela.dev → 收件箱
若其余栈已是 Dart,优先用薄 Dart BFF,让 OTP 密码学与移动端 API 同语言。Node / Go / Python BFF 同样可行——Flutter 侧不变。生产 OTP 不要在 Flutter 工程里用 package:http 直连 Rovela。
服务端环境变量(永远不要放进 App)
# 仅服务端 — Cloud Run / Function / 虚拟机密钥
export ROVELA_API_KEY="re_..." # sending_access
export CODE_PEPPER="long-random-pepper"
export OTP_FROM="Acme <security@mail.acme.com>"
export OTP_TTL_MINUTES=10
export OTP_RESEND_COOLDOWN_SEC=60
export OTP_MAX_ATTEMPTS=5
按普通服务端凭证接入(Authentication、Quick Start)。生产发 OTP 用 sending_access 即可。
Dart 服务端:铸码、HMAC、POST /emails(shelf 草图)
// pubspec.yaml(服务端):shelf, shelf_router, http, crypto, uuid
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:uuid/uuid.dart';
final _pepper = utf8.encode(Platform.environment['CODE_PEPPER']!);
final _apiKey = Platform.environment['ROVELA_API_KEY']!;
final _from = Platform.environment['OTP_FROM'] ?? 'Acme <security@mail.acme.com>';
final _ttl = Duration(minutes: int.parse(Platform.environment['OTP_TTL_MINUTES'] ?? '10'));
final _cooldown = Duration(seconds: int.parse(Platform.environment['OTP_RESEND_COOLDOWN_SEC'] ?? '60'));
const _maxAttempts = 5;
String normalizeEmail(String raw) => raw.trim().toLowerCase();
String mintCode() {
final n = Random.secure().nextInt(1000000); // CSPRNG — 不是 Random()
return n.toString().padLeft(6, '0');
}
String hashCode(String email, String code) {
final mac = Hmac(sha256, _pepper);
return mac.convert(utf8.encode('$email:$code')).toString(); // hex
}
bool safeEqualHex(String a, String b) {
final aa = utf8.encode(a);
final bb = utf8.encode(b);
if (aa.length != bb.length) return false;
var diff = 0;
for (var i = 0; i < aa.length; i++) {
diff |= aa[i] ^ bb[i];
}
return diff == 0;
}
class Record {
Record({required this.codeHash, required this.expiresAt, required this.sentAt, this.attempts = 0});
String codeHash;
int attempts;
DateTime expiresAt;
DateTime sentAt;
}
final store = <String, Record>{}; // 仅演示 — 多实例请用 Redis/SQL
Future<Map<String, dynamic>> sendOtpEmail({
required String to,
required String code,
required String idempotencyKey,
required int ttlMinutes,
}) async {
final body = {
'from': _from,
'to': to,
'subject': 'Your Acme code: $code',
'html':
'<p>Your verification code is</p>'
'<p style="font-size:28px;font-weight:600;letter-spacing:4px">$code</p>'
'<p>It expires in $ttlMinutes minutes. If you did not ask for it, ignore this email.</p>',
'text':
'Your verification code is $code. It expires in $ttlMinutes minutes. '
'If you did not ask for it, ignore this email.',
};
final res = await http.post(
Uri.parse('https://api.rovela.dev/emails'),
headers: {
'Authorization': 'Bearer $_apiKey',
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
'Accept': 'application/json',
},
body: jsonEncode(body),
).timeout(const Duration(seconds: 15));
if (res.statusCode >= 300) {
throw HttpException('rovela send failed: ${res.statusCode} ${res.body}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
Future<Response> handleSend(Request req) async {
final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
final email = normalizeEmail('${payload['email'] ?? ''}');
if (email.isEmpty || !email.contains('@')) {
return Response(400, body: jsonEncode({'error': 'invalid_email'}));
}
final now = DateTime.now().toUtc();
final existing = store[email];
if (existing != null && now.difference(existing.sentAt) < _cooldown) {
return Response(429, body: jsonEncode({'error': 'too_soon'}));
}
final code = mintCode();
final codeHash = hashCode(email, code);
store[email] = Record(
codeHash: codeHash,
expiresAt: now.add(_ttl),
sentAt: now,
);
// 同一逻辑发送用稳定键:哈希前缀。新码 ⇒ 新键。
final idem = 'otp-${codeHash.substring(0, 32)}';
await sendOtpEmail(
to: email,
code: code,
idempotencyKey: idem,
ttlMinutes: _ttl.inMinutes,
);
// 统一文案 — 是否暴露账号存在性由产品决定
return Response.ok(jsonEncode({
'message': '若该地址可收信,验证码已在路上。',
}));
}
Future<Response> handleVerify(Request req) async {
final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
final email = normalizeEmail('${payload['email'] ?? ''}');
final code = '${payload['code'] ?? ''}'.trim();
if (code.length != 6 || int.tryParse(code) == null) {
return Response(400, body: jsonEncode({'error': 'invalid'}));
}
final rec = store[email];
final now = DateTime.now().toUtc();
if (rec == null) return Response(400, body: jsonEncode({'error': 'invalid'}));
if (now.isAfter(rec.expiresAt)) {
store.remove(email);
return Response(400, body: jsonEncode({'error': 'expired'}));
}
if (rec.attempts >= _maxAttempts) {
return Response(429, body: jsonEncode({'error': 'locked'}));
}
if (!safeEqualHex(hashCode(email, code), rec.codeHash)) {
rec.attempts++;
return Response(400, body: jsonEncode({'error': 'invalid'}));
}
store.remove(email);
// 在此签发 session / JWT
return Response.ok(jsonEncode({'ok': true, 'token': 'replace-with-jwt'}));
}
void main() async {
final router = Router()
..post('/auth/otp/send', handleSend)
..post('/auth/otp/verify', handleVerify);
final handler = const Pipeline()
.addMiddleware(logRequests())
// 生产环境请加按 IP 限流中间件
.addHandler(router.call);
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
print('OTP BFF on :${server.port}');
}
建议的表结构(每个邮箱一条有效码),对齐 验证码示例:
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()
);
Cloud Function 提示: 同一套 sendOtpEmail / HMAC 助手也可放进 Dart 或 Node 的 HTTPS 函数。密钥放函数的密钥管理——不要放进 Flutter。
把码放进 subject 有利于锁屏预览——多数产品可接受,共享大屏场景可省略。务必同时发送 html 与 text,并在 text 中带上数字(POST /emails)。
Flutter 客户端:只调你的 BFF
// lib/auth/otp_api.dart — 这里没有 Rovela 密钥
import 'dart:convert';
import 'package:http/http.dart' as http;
class OtpApi {
OtpApi(this.baseUrl); // 例如 https://api.yourapp.com
final String baseUrl;
Future<void> sendCode(String email) async {
final res = await http.post(
Uri.parse('$baseUrl/auth/otp/send'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email.trim()}),
);
if (res.statusCode == 429) {
throw StateError('too_soon');
}
if (res.statusCode >= 300) {
throw StateError('send_failed');
}
}
Future<String> verifyCode(String email, String code) async {
final res = await http.post(
Uri.parse('$baseUrl/auth/otp/verify'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email.trim(), 'code': code.trim()}),
);
if (res.statusCode >= 300) {
throw StateError('invalid_or_locked');
}
final map = jsonDecode(res.body) as Map<String, dynamic>;
return map['token'] as String; // 安全存储
}
}
// lib/screens/otp_login.dart(草图)
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class OtpLoginScreen extends StatefulWidget {
const OtpLoginScreen({super.key, required this.api});
final OtpApi api;
@override
State<OtpLoginScreen> createState() => _OtpLoginScreenState();
}
class _OtpLoginScreenState extends State<OtpLoginScreen> {
final emailCtrl = TextEditingController();
final codeCtrl = TextEditingController();
var step = 0; // 0 = 邮箱, 1 = 验证码
var busy = false;
String? error;
Future<void> _send() async {
setState(() { busy = true; error = null; });
try {
await widget.api.sendCode(emailCtrl.text);
setState(() => step = 1);
} catch (e) {
setState(() => error = e.toString());
} finally {
setState(() => busy = false);
}
}
Future<void> _verify() async {
setState(() { busy = true; error = null; });
try {
final token = await widget.api.verifyCode(emailCtrl.text, codeCtrl.text);
// 用 flutter_secure_storage 持久化 token;再跳转首页
debugPrint('session ready (${token.length} chars)');
} catch (e) {
setState(() => error = e.toString());
} finally {
setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('登录')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
if (step == 0) ...[
TextField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(labelText: '邮箱'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: busy ? null : _send,
child: Text(busy ? '发送中…' : '发送验证码'),
),
] else ...[
TextField(
controller: codeCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 6,
autofillHints: const [AutofillHints.oneTimeCode],
decoration: const InputDecoration(labelText: '六位验证码'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: busy ? null : _verify,
child: Text(busy ? '校验中…' : '验证'),
),
TextButton(
onPressed: busy ? null : _send,
child: const Text('重新发送'),
),
],
if (error != null) Text(error!, style: const TextStyle(color: Colors.red)),
],
),
),
);
}
}
使用 AutofillHints.oneTimeCode,方便 iOS/Android 从通知建议验证码。仍把短信自动填充库当作可选项——邮箱 OTP 落在邮件 App,不是 SMS Retriever 载荷。
Idempotency-Key:重试不会发两封码
Worker、Cloud Functions 重试与不稳定的移动网络都会重试。没有 Idempotency-Key,一次超时就可能让收件箱出现两封不一致的码。
- Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
- 由 code hash 派生(
otp-+ 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。 - 冷却后的主动重发会铸造新码 → 新哈希 → 新键。不要用旧键发新码。
- Flutter 客户端不会对 Rovela 设置该头;你的 BFF 在调
POST /emails时设置。
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-flutter-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."
}'
为什么不要在 Flutter 里直连 Rovela
BFF → POST /emails(本文) | Flutter → 直连 Rovela | |
|---|---|---|
sending_access 密钥 | 服务端密钥管理 | 可从二进制提取 |
| OTP 铸码 + HMAC 存储 | 服务端 | 跨设备安全共享困难 |
| 限流 | 边缘按 IP + 按邮箱 | 攻击者把你的密钥当免费中继 |
| 幂等 | 服务端按铸造稳定键 | 移动端弱网重试易出错 |
| 会话签发 | 验码后在服务端 | 真会话仍需要后端 |
通用 Dart/http「从 CLI 或服务器发欢迎信」见 用 Dart 发邮件。那篇偏传输;本篇是带移动端边界的注册/登录 OTP。
认证数字码的投递注意
OTP 邮件短且敏感。优先用已验证子域,例如 security@mail.example.com,对齐 SPF/DKIM/DMARC,并保持 HTML 简洁。正文(以及常见的 subject)里的码不需要可点链接——点击追踪改写对魔法链接更关键——但打开/点击追踪仍应落在你控制的域名上。
若进垃圾箱,按 为什么 OTP / 魔法链接进垃圾箱 排查。产品该用数字码还是确认链接,见 验证码 vs 链接。
Flutter 里:验证码 vs 魔法链接
| 六位 OTP(本文) | 魔法链接 | |
|---|---|---|
| 用户动作 | 输入数字(适合手机键盘) | 打开 URL(Deep Link / App Link) |
| 秘密熵 | 低 → 必须 HMAC + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Flutter 表面 | 邮箱 + PIN → 你的 BFF | Deep link 处理 → 你的 BFF |
| 点击追踪 | 可选关注 | 安全发信域上建议关闭 |
两者都从服务端调同一个 POST /emails。存储分开——不要把 OTP 记录当成魔法链接 token 复用。
上线前清单
- 服务端用
Random.secure()(或系统 CSPRNG)+ HMAC pepper;永不存明文码;不用非安全的Random()发 OTP。 - 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、BFF 上按 IP 限流。
- upsert / 覆盖会使旧码失效;验码成功后 delete。
- 同时有
html和text;可选把码放进 subject 方便预览。 ROVELA_API_KEY(sending_access)只放服务端——不放 Flutter,不用会随商店包分发的--dart-define。Idempotency-Key对同一次逻辑发送保持稳定;新码 → 新键。- 若隐藏账号存在性,公开文案保持一致。
- Flutter 只调你的 API;使用
AutofillHints.oneTimeCode;会话 token 放安全存储。 - 发信域已验证;进垃圾箱则按清单排查。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
- UX 选择:邮箱验证:验证码 vs 链接。
- 投递:为什么 OTP / 魔法链接进垃圾箱。
- 框架姊妹篇:Express OTP · Go OTP · Hono OTP · Django OTP。
- 仅 Dart 发送(非 OTP):用 Dart 发邮件。
一句话: 让 Flutter 只做 OTP UI;把铸码/HMAC/验码与 Rovela sending_access 密钥放在 Dart shelf(或任意)BFF 上,带 Idempotency-Key 调 POST /emails,永远不要把发送密钥打进移动客户端——Rovela 负责投递,认证仍归你的应用。