首页
/ Supabase × Flutter MFA 示例:TOTP 多因素认证全流程实现与 JWT AAL 行级安全控制

Supabase × Flutter MFA 示例:TOTP 多因素认证全流程实现与 JWT AAL 行级安全控制

2026-09-06 17:37:21作者:廉皓灿Ida

本篇基于 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 MFA 示例应用截图:TOTP 因子注册、挑战验证与受保护数据页面

示例项目概览

该示例是一个标准的 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 逐行拆解:

  1. private_posts:模拟一张存放“敏感信息”的业务表,自增主键 + 非空 content 字段,并插入 3 条示例数据;
  2. alter table ... enable row level security:开启行级安全,此后所有访问都受策略约束;
  3. 核心策略for select to authenticated using ((select auth.jwt()->>'aal') = 'aal2')——只允许已认证角色读取,且读取 JWT 载荷中的 aal(Authenticator Assurance Level)字段,仅当其值为 aal2 时才放行。

这里的机制值得展开:aal 是 Supabase Auth 签发给每个会话的 JWT claim,aal1 表示仅通过了第一因素(邮箱密码),aal2 表示已通过 MFA 第二因素验证。密码登录成功时 aalaal1,在完成一次 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 写法)。

随后定义 GoRouterredirect 回调,它实现了整个应用的分发逻辑:

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 == aal1nextLevel != aal2 注册页(MFA 注册)/mfa/enroll
已登录且已注册 MFA、但本次会话未验证 currentLevel == aal1nextLevel == 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 调用遵循固定顺序:

  1. mfa.enroll():创建 TOTP 因子,服务端生成密钥与二维码;
  2. mfa.challenge(factorId:):为该因子发起一次性挑战,返回含 id 的 challenge 对象;
  3. mfa.verify(factorId:, challengeId:, code:):提交 6 位动态码,服务端校验该 TOTP 是否有效;
  4. 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,然后同样走 challengeverifyrefreshSession 的标准链路。这个 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.namestatus.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 方案时的几个要点,均可在上文源码中对应找到:

  1. AAL 是贯穿三层的纽带:客户端路由守卫读 getAuthenticatorAssuranceLevel(),JWT 携带 aal claim,RLS 策略以 auth.jwt()->>'aal' = 'aal2' 做最终裁决,三者语义一致;
  2. challenge 必须紧跟 verify:挑战是一次性的,verify 必须携带 challenge.id,示例在用户输满 6 位时立即触发,避免提前发起;
  3. 验证后必须 refreshSession():否则本地缓存的 access token 仍是 aal1,既过不了路由守卫,也无法通过 RLS;
  4. 注册流程用 emailRedirectTo Deep Link 回跳 MFA 注册页,让邮箱确认与第二因素绑定衔接起来;
  5. 注销因子后应主动 signOut(),与示例保持一致,避免处于“已登录但无第二因素”的中间态。

以上全部代码与配置均可在仓库中直接查阅:说明文档见 README,路由与守卫见 main.dart,注册与验证实现分别见 enroll_page.dartverify_page.dart,依赖清单见 pubspec.yaml

登录后查看全文
热门项目推荐
相关项目推荐