Supabase × Flutter MFA 示例:TOTP 多因素认证全流程实现与 JWT AAL 行级安全控制
本篇基于 Supabase 官方仓库中的 Flutter MFA 示例(examples/auth/flutter-mfa),完整拆解一个可在移动端落地运行的多因素认证(Multi-Factor Authentication,MFA)方案:从数据库侧用 RLS 策略校验 JWT 中的 aal2 断言,到 Flutter 侧用 supabase_flutter SDK 完成 TOTP 因子注册(Enroll)、挑战(Challenge)、验证(Verify)与因子注销(Unenroll)的全链路调用。读完本文,你能掌握 Authenticator Assurance Level(AAL)的工作机制、supabase.auth.mfa 各方法的真实调用顺序,以及如何把 MFA 状态与路由守卫、行级安全策略绑定起来,构建“未通过 MFA 就无法读取敏感数据”的闭环。
示例项目概览
该示例是一个标准的 Flutter 工程,README 中的定位是:“A Flutter app demonstrating how to implement Multi-Factor Authentication (MFA) with Supabase and Flutter”——用户可以注册账号、通过 Authenticator App 添加 MFA 因子,并且只有在完成 MFA 登录后才能查看数据库中的内容。
从 pubspec.yaml 可以确认该示例的核心依赖与适用前提:
name: mfa_app
dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.0.0 # Supabase Flutter SDK 2.x
go_router: ^7.0.0 # 路由管理,承载 MFA 状态守卫
flutter_svg: ^2.0.5 # 用于渲染 enroll 返回的二维码 SVG
即本方案要求 supabase_flutter 2.x 版本,并使用 go_router 做声明式路由,flutter_svg 负责把注册接口返回的二维码 SVG 字符串绘制出来。
整个 lib/ 目录按页面职责组织,与 MFA 生命周期一一对应:
lib/
main.dart # SDK 初始化 + go_router 路由与 MFA 守卫
pages/
auth/
login_page.dart # 邮箱密码登录
register_page.dart # 注册(含邮箱确认后的 Deep Link 回跳)
mfa/
enroll_page.dart # TOTP 因子注册:展示二维码 + 输入 6 位码挑战验证
verify_page.dart # 已注册因子用户的 MFA 验证页
home_page.dart # 读取 RLS 保护的 private_posts 数据
list_mfa_page.dart # 列出全部因子并支持注销(unenroll)
准备工作:数据库表、种子数据与 RLS 策略
README 给出的上手步骤是:创建一个 Supabase 项目,把凭据填入客户端代码,然后在 Dashboard 的 SQL Editor 中执行如下 SQL。这段 SQL 是整个方案的“安全底座”,完整保留如下:
-- Dummy table that contains "secure" information
create table if not exists public.private_posts (
id int generated by default as identity primary key,
content text not null
);
-- Dmmy "secure" data
insert into public.private_posts
(content)
values
('Flutter is awesome!'),
('Supabase is awesome!'),
('Postgres is awesome!');
-- Enable RLS for private_posts table
alter table public.private_posts enable row level security;
-- Create a policy that only allows read if they user has signed in via MFA
create policy "Users can view private_posts if they have signed in via MFA"
on public.private_posts
for select
to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
这段 SQL 逐行拆解:
private_posts表:模拟一张存放“敏感信息”的业务表,自增主键 + 非空content字段,并插入 3 条示例数据;alter table ... enable row level security:开启行级安全,此后所有访问都受策略约束;- 核心策略:
for select to authenticated using ((select auth.jwt()->>'aal') = 'aal2')——只允许已认证角色读取,且读取 JWT 载荷中的aal(Authenticator Assurance Level)字段,仅当其值为aal2时才放行。
这里的机制值得展开:aal 是 Supabase Auth 签发给每个会话的 JWT claim,aal1 表示仅通过了第一因素(邮箱密码),aal2 表示已通过 MFA 第二因素验证。密码登录成功时 aal 为 aal1,在完成一次 TOTP 挑战验证(或注册时的首次 challenge-verify)后,refreshSession() 会拿到带 aal2 的新 access token。因此 RLS 策略实际上把“是否完成 MFA”这个认证状态,翻译成了数据库层的读权限判断——即使客户端绕过 UI 直接发查询请求,Postgres 侧依然会因为 JWT 中 aal 不是 aal2 而拒绝返回任何行。这是“客户端校验”与“数据库强制”分离的关键:UI 路由守卫负责体验,RLS 策略负责兜底。
客户端初始化与基于 AAL 的路由守卫
main.dart 是理解整个 MFA 状态机的入口。首先初始化 SDK:
void main() async {
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
publishableKey: 'YOUR_PUBLISHABLE_KEY',
);
runApp(const MyApp());
}
/// Extract SupabaseClient instance in a handy variable
final supabase = Supabase.instance.client;
这里使用 Supabase 新版凭据体系:项目 URL + publishableKey(取代旧的 anonKey 写法)。
随后定义 GoRouter 的 redirect 回调,它实现了整个应用的分发逻辑:
redirect: (context, state) async {
// Any users can visit the /auth route
if (state.location.contains('/auth') == true) {
return null;
}
final session = supabase.auth.currentSession;
// A user without a session should be redirected to the register page
if (session == null) {
return RegisterPage.route;
}
final assuranceLevelData =
supabase.auth.mfa.getAuthenticatorAssuranceLevel();
// The user has not setup MFA yet, so send them to enroll MFA page.
if (assuranceLevelData.currentLevel == AuthenticatorAssuranceLevels.aal1) {
await supabase.auth.refreshSession();
final nextLevel =
supabase.auth.mfa.getAuthenticatorAssuranceLevel().nextLevel;
if (nextLevel == AuthenticatorAssuranceLevels.aal2) {
// The user has already setup MFA, but haven't login via MFA
// Redirect them to the verify page
return MFAVerifyPage.route;
} else {
// The user has not yet setup MFA
// Redirect them to the enrollment page
return MFAEnrollPage.route;
}
}
// The user has signed invia MFA, and is allowed to view any page.
return null;
},
从源码结构看,这段守卫把用户划分为四种状态:
| 会话状态 | 判定条件 | 跳转目标 |
|---|---|---|
| 无会话 | supabase.auth.currentSession == null |
注册页 /auth/register |
| 已登录但未注册 MFA | currentLevel == aal1 且 nextLevel != aal2 |
注册页(MFA 注册)/mfa/enroll |
| 已登录且已注册 MFA、但本次会话未验证 | currentLevel == aal1 且 nextLevel == aal2 |
MFA 验证页 /mfa/verify |
| 已通过 MFA | currentLevel == aal2 |
放行(return null),进入主页 / |
两个 API 细节值得注意:
getAuthenticatorAssuranceLevel()返回当前会话的 AAL 信息,包含currentLevel(当前保障级别)与nextLevel(通过一次 MFA 验证后可达到的级别)。nextLevel的存在直接揭示了“是否已注册因子”——如果用户还没注册任何 MFA 因子,验证后级别不会提升,nextLevel就不会是aal2;- 进入
aal1分支时先调用refreshSession()再读取一次 AAL,确保拿到的是最新的会话状态,避免用过期 JWT 做判定。
路由表本身是平铺的 6 条 GoRoute:/(主页)、/list-mfa、/auth/login、/auth/register、/mfa/enroll、/mfa/verify,/auth 前缀的页面允许任意用户访问,其余页面全部经过上述 redirect 守卫。
MFA 注册流程:enroll → challenge → verify
enroll_page.dart 实现了“注册 TOTP 因子并当场验证”的完整流程。页面构造时立即发起注册请求:
final _enrollFuture = supabase.auth.mfa.enroll();
enroll() 返回的响应中包含三个关键字段,页面用 FutureBuilder 消费后分别渲染:
final response = snapshot.data!;
final qrCodeUrl = response.totp.qrCode; // 二维码 SVG 字符串
final secret = response.totp.secret; // TOTP 密钥(可手动粘贴到验证器 App)
final factorId = response.id; // 因子 ID,后续 challenge/verify 必传
UI 上通过 SvgPicture.string(qrCodeUrl) 把二维码 SVG 绘制出来(这就是 flutter_svg 依赖的用武之地),并展示 secret 供用户手动输入,附带一个复制到剪贴板的按钮:
SvgPicture.string(
qrCodeUrl,
width: 150,
height: 150,
),
用户输入 6 位动态码后,触发“挑战-验证”两步调用:
onChanged: (value) async {
if (value.length != 6) return;
// kick off the verification process once 6 characters are entered
try {
final challenge =
await supabase.auth.mfa.challenge(factorId: factorId);
await supabase.auth.mfa.verify(
factorId: factorId,
challengeId: challenge.id,
code: value,
);
await supabase.auth.refreshSession();
if (mounted) {
context.go(HomePage.route);
}
} on AuthException catch (error) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(error.message)));
} catch (error) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Unexpected error occurred')));
}
},
从源码可以看出 supabase_flutter 的 MFA 调用遵循固定顺序:
mfa.enroll():创建 TOTP 因子,服务端生成密钥与二维码;mfa.challenge(factorId:):为该因子发起一次性挑战,返回含id的 challenge 对象;mfa.verify(factorId:, challengeId:, code:):提交 6 位动态码,服务端校验该 TOTP 是否有效;auth.refreshSession():刷新会话,使新 JWT 携带aal2,随后路由跳转回主页。
错误处理统一捕获 AuthException(例如 code 校验失败),以 SnackBar 展示服务端返回的 message,与示例中其他页面保持一致的容错模式。
MFA 验证流程:listFactors → challenge → verify
对于已经注册过因子的用户(例如换设备或重新登录后),verify_page.dart 承担“第二因素验证”职责。与注册页不同,验证页需要先查询已有因子:
onChanged: (value) async {
if (value.length != 6) return;
try {
final factorsResponse = await supabase.auth.mfa.listFactors();
final factor = factorsResponse.totp.first;
final factorId = factor.id;
final challenge =
await supabase.auth.mfa.challenge(factorId: factorId);
await supabase.auth.mfa.verify(
factorId: factorId,
challengeId: challenge.id,
code: value,
);
await supabase.auth.refreshSession();
if (mounted) {
context.go(HomePage.route);
}
} on AuthException catch (error) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(error.message)));
}
// ...
},
与注册页相比,差异只有第一步:验证页通过 mfa.listFactors() 拿到 factorsResponse.totp 列表并取第一个 TOTP 因子的 id,然后同样走 challenge → verify → refreshSession 的标准链路。这个 refreshSession() 正是让 JWT 从 aal1 升级为 aal2 的关键调用——调用成功后,路由守卫中 getAuthenticatorAssuranceLevel().currentLevel 变为 aal2,RLS 策略也随之放行 private_posts 的读取。
login_page.dart 展示了第一因素入口:邮箱密码调用 supabase.auth.signInWithPassword(email:, password:),成功后显式跳转 MFAVerifyPage.route,与路由守卫形成双保险。
register_page.dart 中的注册调用则有一个 MFA 场景特有的参数:
await supabase.auth.signUp(
email: email,
password: password,
emailRedirectTo:
'mfa-app://callback${MFAEnrollPage.route}', // redirect the user to setup MFA page after email confirmation
);
emailRedirectTo 指定了邮箱确认后的回跳地址。这里使用了自定义 Deep Link scheme mfa-app://,并把回调路径拼在 MFA 注册页路由(/mfa/enroll)之前——即新用户完成邮箱确认后,被直接引导进 MFA 注册流程,保证“注册即绑定第二因素”的体验闭环。
受保护数据读取与因子生命周期管理
home_page.dart 是 MFA 通过后的“奖励页”,直接读取受 RLS 保护的表:
final privatePostsFuture = supabase.from('private_posts').select();
由于主页在路由守卫中只有 aal2 才能到达,且 JWT 中的 aal=aal2 同时满足 RLS 策略,这里的 select() 才能返回 3 条种子数据,并用 ListView.builder 渲染成列表。如果绕过守卫直接请求该表(例如用 aal1 的 token),Postgres 会返回空集——数据库层兜底生效。
list_mfa_page.dart 演示了因子的查看与注销。列表页同样基于 mfa.listFactors(),遍历 response.all 渲染每个因子的 friendlyName ?? factorType.name 与 status.name;注销流程为:
onPressed: () async {
await supabase.auth.mfa.unenroll(factor.id);
await supabase.auth.signOut();
if (context.mounted) {
context.go(RegisterPage.route);
}
},
即调用 mfa.unenroll(factorId) 删除因子后立即 signOut() 并回到注册页。源码中的确认对话框文案也点明了这一语义:删除因子后用户会被登出——因为移除第二因素后,会话的 aal 无法维持 aal2,示例选择直接结束会话回到起点。
完整调用链与要点回顾
把示例的 MFA 生命周期串起来,supabase_flutter SDK 提供的 auth.mfa 命名空间共涉及 6 个方法,示例中的调用顺序为:
signUp / signInWithPassword(第一因素,JWT aal1)
→ getAuthenticatorAssuranceLevel()(守卫判定 currentLevel / nextLevel)
→ 未注册因子:mfa.enroll() 创建 TOTP 因子(qrCode / secret / factorId)
→ mfa.challenge(factorId:) 发起挑战
→ mfa.verify(factorId:, challengeId:, code:) 提交 6 位动态码
→ auth.refreshSession() 刷新会话(JWT 升级为 aal2)
→ from('private_posts').select() 读取受 RLS 保护的数据
→(可选)mfa.listFactors() / mfa.unenroll(factorId) 管理因子生命周期
实现此类 MFA 方案时的几个要点,均可在上文源码中对应找到:
- AAL 是贯穿三层的纽带:客户端路由守卫读
getAuthenticatorAssuranceLevel(),JWT 携带aalclaim,RLS 策略以auth.jwt()->>'aal' = 'aal2'做最终裁决,三者语义一致; challenge必须紧跟verify:挑战是一次性的,verify必须携带challenge.id,示例在用户输满 6 位时立即触发,避免提前发起;- 验证后必须
refreshSession():否则本地缓存的 access token 仍是aal1,既过不了路由守卫,也无法通过 RLS; - 注册流程用
emailRedirectToDeep Link 回跳 MFA 注册页,让邮箱确认与第二因素绑定衔接起来; - 注销因子后应主动
signOut(),与示例保持一致,避免处于“已登录但无第二因素”的中间态。
以上全部代码与配置均可在仓库中直接查阅:说明文档见 README,路由与守卫见 main.dart,注册与验证实现分别见 enroll_page.dart 和 verify_page.dart,依赖清单见 pubspec.yaml。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
