Flutter 基础体系 · 第 49/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Deep Link 与 Universal Link:配置、解析、登录和安全

1. 先区分几个容易混淆的术语

Deep Link 是“能够直接把用户带到应用内某个资源或页面的链接”。例如:

https://example.com/orders/123

用户点击后,应用可以直接打开订单 123,而不是只打开首页。

Deep Link 是一个行为和能力的统称,不限定实现方式。常见实现包括:

  • Custom URL Scheme:例如 myapp://orders/123
  • Android App Link:Android 对 https 链接的域名归属验证机制。
  • iOS Universal Link:iOS 对 https 链接的应用关联机制。
  • Web URL:Flutter Web 直接由浏览器访问同一个 https 地址。

Universal Link 是 iOS 的专有名称。它通常要求应用声明自己与某个 HTTPS 域名关联,并由域名上的 apple-app-site-association 文件证明这种关联。用户点击普通 HTTPS 链接时,iOS 可以在已安装应用的情况下直接打开应用;没有安装应用时,仍然可以打开网站。

Android 中功能相近的机制叫 Android App Links。两者都属于“经过域名验证的 HTTPS Deep Link”,但配置文件、系统行为和验证流程不同:

机制 平台 链接形式 域名验证
Custom URL Scheme Android、iOS、桌面等 myapp://... 没有强制的域名所有权验证
Android App Links Android https://... assetlinks.json
Universal Links iOS https://... apple-app-site-association
Web 路由 Web https://... 由网站和浏览器处理

因此,“Deep Link”是问题域,“Universal Link”是其中一种 iOS 实现。不能把所有 Deep Link 都称为 Universal Link。


2. 一条 Deep Link 的完整数据流

一个链接从点击到 Flutter 页面,通常经过以下组件:

sequenceDiagram
    participant U as 用户
    participant OS as 操作系统
    participant W as 网站验证文件
    participant A as Flutter 应用
    participant R as 路由解析器
    participant S as 会话/登录状态
    participant P as 页面

    U->>OS: 点击 HTTPS 或自定义 Scheme
    OS->>W: 验证应用与域名的关联
    W-->>OS: 返回允许的应用、路径和签名
    OS->>A: 冷启动或唤醒应用并传入 URI
    A->>R: 读取初始链接或后续链接
    R->>R: 解析 scheme、host、path、query、fragment
    R->>S: 检查登录和授权状态
    alt 已登录且有权限
        S-->>R: 允许访问
        R->>P: 构造目标页面
    else 未登录
        S-->>R: 暂存受保护目标
        R->>P: 显示登录页
        P->>S: 登录成功
        S->>R: 恢复并重新校验目标
        R->>P: 打开目标页面
    else 无权限或链接非法
        R->>P: 显示错误、登录页或首页
    end

这里有三个不同层次,必须分别处理:

  1. 操作系统分发:这个链接交给哪个应用。
  2. Flutter 接收:应用冷启动、后台恢复或前台运行时如何取得链接。
  3. 应用路由:链接中的路径和参数如何转换成页面,并经过登录、授权和安全校验。

配置文件只能解决第一层,不能自动解决后两层。即使系统成功唤醒了应用,如果 Flutter 没有处理新 URI,用户仍然可能只看到首页。


3. 链接的形式化结构与解析规则

一个 URI 可以抽象为:

scheme://authority/path?query#fragment

例如:

https://example.com/orders/123?from=email#summary

各部分含义如下:

  • scheme:协议,如 httpsmyapp
  • authority:通常包含 host 和可选的端口。
  • host:域名,如 example.com
  • path:资源路径,如 /orders/123
  • query:查询参数,如 from=email
  • fragment:片段,如 summary。Fragment 通常不会发送给服务器,但应用可以读取它。

Flutter/Dart 使用 Uri 解析 URI:

void main() {
  final uri = Uri.parse(
    'https://example.com/orders/123?from=email#summary',
  );

  print(uri.scheme);              // https
  print(uri.host);                // example.com
  print(uri.path);                // /orders/123
  print(uri.pathSegments);        // [orders, 123]
  print(uri.queryParameters);     // {from: email}
  print(uri.fragment);            // summary
}

3.1 不要直接用字符串切割路径

下面的代码存在边界问题:

final id = uri.toString().split('/').last;

它没有正确处理查询参数、尾部斜杠、编码字符和错误 URI。例如:

/orders/123?from=email

最后一段可能变成 123?from=email,而不是订单 ID 123

应使用 pathSegments

String? parseOrderId(Uri uri) {
  final segments = uri.pathSegments;

  if (segments.length == 2 &&
      segments[0] == 'orders' &&
      segments[1].isNotEmpty) {
    return segments[1];
  }

  return null;
}

解析过程是:

  1. /orders/123 被拆成 ['orders', '123']
  2. 第一段必须是固定资源名 orders
  3. 第二段被视为候选 ID。
  4. 不符合结构时返回 null,而不是猜测用户意图。

3.2 查询参数不是可信数据

final returnTo = uri.queryParameters['return_to'];

这只能表示“用户提供了一个字符串”,不能表示它是一个安全的返回地址。下面这个链接可能造成开放重定向:

https://example.com/login?return_to=https://attacker.example

如果应用登录后无条件跳转到 return_to,攻击者可以利用可信域名把用户引到钓鱼页面。

更安全的做法是将返回目标表示为内部路由,而不是任意 URL:

sealed class AppTarget {
  const AppTarget();
}

final class OrderTarget extends AppTarget {
  const OrderTarget(this.orderId);

  final String orderId;
}

final class HomeTarget extends AppTarget {
  const HomeTarget();
}

AppTarget parseTarget(Uri uri) {
  if (uri.scheme != 'https' || uri.host != 'example.com') {
    return const HomeTarget();
  }

  final segments = uri.pathSegments;

  if (segments.length == 2 &&
      segments[0] == 'orders' &&
      segments[1].isNotEmpty) {
    return OrderTarget(segments[1]);
  }

  return const HomeTarget();
}

这个解析器只允许应用定义过的资源类型。它不会把任意外部 URI 当成内部导航指令。

3.3 URL 编码必须在正确的层次解码

例如订单 ID 是:

orders/a%2Fb

Uri 解析后,pathSegments 会处理路径段编码。应用不应先手动调用 decodeComponent,再把解码后的 / 当成路径分隔符,否则一个 ID 可能被错误地拆成两个路径段。

同样,查询参数应使用:

uri.queryParameters['keyword']

而不是手动按 &= 分割。Dart 的 Uri 会处理查询参数编码和重复键的基本解析;如果业务允许重复键,应明确使用 queryParametersAll


4. Android:Custom Scheme 与 App Links

4.1 Custom URL Scheme

Android 可以通过 intent-filter 声明一个自定义 Scheme:

<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
        android:label="example"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">

            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>

            <intent-filter>
                <action android:name="android.intent.action.VIEW"/>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="android.intent.category.BROWSABLE"/>

                <data android:scheme="myapp"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

下面的链接将匹配该过滤器:

myapp://orders/123

BROWSABLE 允许来自浏览器等外部上下文的跳转,DEFAULT 允许普通 Intent 匹配。自定义 Scheme 的主要问题是:任何其他应用都可以注册同一个 Scheme,系统不一定能区分真正的应用。因此它不适合单独承担高价值登录回调或敏感数据传递。

4.2 Android App Links

对于生产环境的 HTTPS 链接,通常使用 App Links:

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>

    <data
        android:scheme="https"
        android:host="example.com"
        android:pathPrefix="/orders"/>
</intent-filter>

android:autoVerify="true" 请求 Android 验证应用是否拥有该域名的关联声明。它不是“强制验证成功”的保证;域名文件错误、HTTPS 证书问题、应用签名不匹配或系统验证失败,都会使系统退化为普通网页链接或弹出选择器。

在网站上部署:

https://example.com/.well-known/assetlinks.json

示例内容:

[
  {
    "relation": [
      "delegate_permission/common.handle_all_urls"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.example",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]

关键字段的因果关系是:

  • package_name 必须与 Android 应用的 application ID 一致。
  • sha256_cert_fingerprints 必须是签名证书指纹,不是上传密钥、调试密钥或包名。
  • 生产包、内部测试包和调试包可能使用不同签名,因此可以需要不同的应用条目。
  • 文件必须通过 HTTPS 提供,并位于精确的 /.well-known/assetlinks.json 路径。

可用以下命令查看签名证书指纹:

keytool -list -v \
  -keystore release-keystore.jks \
  -alias release

输出中的 SHA256 值需要转换为 assetlinks.json 使用的冒号分隔大写格式。不要把真实 keystore 密码提交到脚本或日志中。

Android 端可以使用 ADB 发起测试:

adb shell am start \
  -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://example.com/orders/123"

这个命令只验证 Intent 能否发出和应用能否收到;它不能单独证明域名验证正确。验证失败时,应进一步检查:

adb shell pm get-app-links com.example.example

不同 Android 版本的输出格式可能不同,但应关注域名的验证状态。还要检查:

  1. assetlinks.json 是否返回 HTTP 200。
  2. 是否被重定向。
  3. Content-Type 是否合理。
  4. JSON 是否有效。
  5. 包名和签名指纹是否对应当前安装的 APK。
  6. Manifest 中的 host、scheme、path 是否与实际 URL 一致。

5. iOS:Universal Link 的配置与限制

5.1 Associated Domains 能做什么

在 iOS 工程中启用 Associated Domains 能力,并加入:

applinks:example.com

如果使用多个域名,需要分别声明:

applinks:example.com
applinks:staging.example.com

这个配置表达的是:应用希望接收这些域名的 Universal Link。它本身还不够,网站必须提供对应的关联文件。

5.2 apple-app-site-association

部署到以下任一位置:

https://example.com/.well-known/apple-app-site-association

或者:

https://example.com/apple-app-site-association

典型文件如下:

{
  "applinks": {
    "details": [
      {
        "appIDs": [
          "ABCDE12345.com.example.example"
        ],
        "components": [
          {
            "/": "/orders/*"
          }
        ]
      }
    ]
  }
}

appIDs 的格式是:

TeamID.BundleID

例如:

ABCDE12345.com.example.example

这里的 TeamID 是 Apple Developer Team ID,不是 App Store ID。BundleID 必须与应用的 Bundle Identifier 相同。

兼容较旧系统或既有项目时,也可能看到旧格式:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCDE12345.com.example.example",
        "paths": [
          "/orders/*"
        ]
      }
    ]
  }
}

实际使用的字段能力受目标 iOS 版本和项目配置影响,生产项目应根据支持的最低系统版本验证文件格式。

文件要求包括:

  • 文件名没有 .json 后缀。
  • 通过 HTTPS 提供。
  • 不依赖登录。
  • 内容是有效 JSON。
  • 服务器不能要求 Cookie 或特殊请求头。
  • 不应被错误的重定向、缓存策略或 CDN 规则拦截。

5.3 Universal Link 的用户行为不是绝对强制

即使配置完全正确,iOS 也可能因为用户之前选择在 Safari 打开、系统缓存、应用状态或具体点击上下文而打开网页。Universal Link 是系统的链接分发机制,不是应用可以强制夺取所有网页点击的权限。

因此,产品必须允许以下结果同时存在:

  • 应用已安装,打开应用。
  • 应用未安装,打开网站。
  • 用户明确选择后,后续倾向于继续打开网站。
  • 某些应用内浏览器不完全遵循系统行为。

iOS 端测试时,应使用真实 HTTPS URL,并从备忘录、邮件、Safari 等实际入口测试。直接在某些调试工具中粘贴 URL,不一定复现用户点击链接的行为。


6. Flutter 如何接收链接

Flutter 处理 Deep Link 有两个阶段:

  • 初始链接:应用因链接冷启动或从终止状态恢复时得到的 URI。
  • 后续链接:应用已经运行,用户再次点击链接时收到的 URI。

Flutter 的路由系统可以处理初始路由信息,但“前台运行时持续监听平台链接”通常需要平台集成或插件。常见做法是使用社区插件,例如 app_links;它不是 Flutter SDK API,必须锁定版本并根据所用版本检查 API。

pubspec.yaml 中加入依赖后,下面是一个典型入口:

import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const App());
}

class App extends StatefulWidget {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  final _appLinks = AppLinks();

  StreamSubscription<Uri>? _subscription;
  Uri? _initialUri;
  String? _error;

  @override
  void initState() {
    super.initState();
    _readInitialLink();
    _listenToLinks();
  }

  Future<void> _readInitialLink() async {
    try {
      final uri = await _appLinks.getInitialLink();

      if (!mounted) return;

      setState(() {
        _initialUri = uri;
      });

      if (uri != null) {
        _handleUri(uri);
      }
    } catch (error) {
      if (!mounted) return;

      setState(() {
        _error = '读取初始链接失败:$error';
      });
    }
  }

  void _listenToLinks() {
    _subscription = _appLinks.uriLinkStream.listen(
      _handleUri,
      onError: (Object error, StackTrace stackTrace) {
        if (!mounted) return;

        setState(() {
          _error = '监听链接失败:$error';
        });
      },
    );
  }

  void _handleUri(Uri uri) {
    final target = parseTarget(uri);

    if (target is OrderTarget) {
      // 实际项目中应提交给统一导航协调器,
      // 而不是在任意回调里直接 push。
      debugPrint('收到订单链接:${target.orderId}');
    } else {
      debugPrint('收到不支持的链接:$uri');
    }
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Deep Link Demo')),
        body: Center(
          child: Text(_error ?? '等待链接;初始链接:$_initialUri'),
        ),
      ),
    );
  }
}

这个例子展示了接收生命周期,但仍有一个重要工程约束:_handleUri 不应直接在任意时机调用 Navigator.push。应用可能正在:

  • 显示启动页;
  • 恢复登录状态;
  • 构造 Navigator;
  • 处理另一个链接;
  • 显示登录页面;
  • 恢复系统状态。

因此更可靠的结构是:平台链接先进入一个“导航意图队列”,等应用完成初始化后统一消费。

Flutter 官方路由体系包括 NavigatorRouteInformationParserRouterDelegateMaterialApp.router。简单应用可以使用 Navigator;需要 URL 与页面状态双向同步时,应使用 Router API 或基于它的路由包。无论采用哪种路由方案,Deep Link 的安全解析都应位于路由入口之前或入口之内的统一层,而不应散落在各个页面。


7. 一个可运行的安全解析器

下面的代码不依赖 Flutter,只依赖 Dart 3 的模式匹配和 sealed class,可以直接保存为 bin/parse_link.dart 运行。

sealed class LinkTarget {
  const LinkTarget();
}

final class OrderTarget extends LinkTarget {
  const OrderTarget(this.id);

  final String id;

  @override
  String toString() => 'OrderTarget(id: $id)';
}

final class LoginCallbackTarget extends LinkTarget {
  const LoginCallbackTarget({
    required this.code,
    required this.state,
  });

  final String code;
  final String state;

  @override
  String toString() => 'LoginCallbackTarget(code: $code, state: $state)';
}

final class HomeTarget extends LinkTarget {
  const HomeTarget();

  @override
  String toString() => 'HomeTarget';
}

final class InvalidTarget extends LinkTarget {
  const InvalidTarget(this.reason);

  final String reason;

  @override
  String toString() => 'InvalidTarget(reason: $reason)';
}

LinkTarget parseLink(Uri uri) {
  // 1. 先校验 scheme 和 host,再解析业务路径。
  final isHttps =
      uri.scheme == 'https' && uri.host.toLowerCase() == 'example.com';

  final isCustomScheme = uri.scheme == 'myapp';

  if (!isHttps && !isCustomScheme) {
    return const InvalidTarget('scheme 或 host 不被允许');
  }

  final segments = uri.pathSegments;

  // 2. 订单 Deep Link:/orders/<id>
  if (segments.length == 2 && segments[0] == 'orders') {
    final id = segments[1];

    if (!_isSafeIdentifier(id)) {
      return const InvalidTarget('订单 ID 格式非法');
    }

    return OrderTarget(id);
  }

  // 3. OAuth 登录回调:/auth/callback?code=...&state=...
  if (segments.length == 2 &&
      segments[0] == 'auth' &&
      segments[1] == 'callback') {
    final code = uri.queryParameters['code'];
    final state = uri.queryParameters['state'];

    if (code == null || state == null || code.isEmpty || state.isEmpty) {
      return const InvalidTarget('登录回调缺少 code 或 state');
    }

    return LoginCallbackTarget(code: code, state: state);
  }

  return const HomeTarget();
}

bool _isSafeIdentifier(String value) {
  // 示例策略:只允许长度有限的字母、数字、短横线和下划线。
  final pattern = RegExp(r'^[A-Za-z0-9_-]{1,128}$');
  return pattern.hasMatch(value);
}

void main() {
  const inputs = [
    'https://example.com/orders/123',
    'https://example.com/orders/123?from=email',
    'myapp://auth/callback?code=abc&state=s1',
    'https://attacker.example/orders/123',
    'https://example.com/orders/a%2Fb',
  ];

  for (final input in inputs) {
    final uri = Uri.parse(input);
    print('$input => ${parseLink(uri)}');
  }
}

预期结果类似:

https://example.com/orders/123 => OrderTarget(id: 123)
https://example.com/orders/123?from=email => OrderTarget(id: 123)
myapp://auth/callback?code=abc&state=s1 => LoginCallbackTarget(code: abc, state: s1)
https://attacker.example/orders/123 => InvalidTarget(reason: scheme 或 host 不被允许)
https://example.com/orders/a%2Fb => InvalidTarget(reason: 订单 ID 格式非法)

这里的处理顺序是有意设计的:

  1. 先限制允许的 Scheme 和 Host。
  2. 再限制路径结构。
  3. 再限制资源 ID 的字符集和长度。
  4. 最后才构造业务对象。

如果顺序反过来,应用可能在完成安全校验前就触发网络请求、页面跳转或状态修改。


8. Deep Link 与 Flutter 路由状态

一个 Deep Link 不应等同于一次简单的 push。原因是路由具有状态,而且可能需要恢复、替换或清空已有页面栈。

例如当前页面栈是:

Home -> ProductList -> ProductDetail(10)

此时用户点击:

https://example.com/orders/123

直接执行:

Navigator.of(context).push(
  MaterialPageRoute(
    builder: (_) => const OrderPage(orderId: '123'),
  ),
);

结果可能是:

Home -> ProductList -> ProductDetail(10) -> Order(123)

这未必符合产品意图。用户可能期望的是:

Home -> Order(123)

或者如果订单页是外部入口,则期望清空到:

Order(123)

因此需要先定义 Deep Link 的路由语义:

  • 追加型:在当前栈上打开目标。
  • 替换型:用目标页面替换当前页面。
  • 重置型:清空页面栈后进入目标。
  • 待登录型:先进入登录流程,成功后再恢复目标。
  • 不可达型:资源不存在、无权限或链接过期时进入错误页面。

在 Router 架构中,可以把 URI 转换为不可变的应用路由状态:

sealed class AppRoute {
  const AppRoute();
}

final class HomeRoute extends AppRoute {
  const HomeRoute();
}

final class OrderRoute extends AppRoute {
  const OrderRoute(this.id);

  final String id;
}

final class LoginRoute extends AppRoute {
  const LoginRoute(this.pending);

  final AppRoute pending;
}

AppRoute routeFromUri(Uri uri) {
  return switch (parseLink(uri)) {
    OrderTarget(:final id) => OrderRoute(id),
    LoginCallbackTarget() => const HomeRoute(),
    HomeTarget() => const HomeRoute(),
    InvalidTarget() => const HomeRoute(),
  };
}

真实项目中,routeFromUri 还应与登录状态、服务端授权结果和资源加载状态协同。一个常见的状态机如下:

stateDiagram-v2
    [*] --> Booting
    Booting --> Ready: 初始化完成
    Booting --> PendingLink: 已收到链接但应用未就绪
    PendingLink --> Ready: 初始化完成
    Ready --> Resolving: 收到 URI
    Resolving --> ShowingPublicPage: 公开目标
    Resolving --> WaitingLogin: 目标需要登录
    Resolving --> ErrorPage: URI 非法
    WaitingLogin --> Revalidating: 登录成功
    WaitingLogin --> Ready: 用户取消登录
    Revalidating --> ShowingProtectedPage: 会话有效且有权限
    Revalidating --> ErrorPage: 无权限、资源不存在或目标过期
    ShowingPublicPage --> Resolving: 收到新 URI
    ShowingProtectedPage --> Resolving: 收到新 URI

PendingLink 很重要。冷启动时,平台链接可能早于:

  • 本地 Token 加载;
  • 远程配置加载;
  • 用户信息恢复;
  • Flutter Router 创建;
  • 数据库初始化。

如果此时直接导航,常见失败表现包括:

  • 首屏先显示首页,稍后又跳转目标页,出现闪烁。
  • 目标页依赖用户信息,但因为会话尚未恢复而错误跳登录。
  • 同一个链接被初始回调和流回调各处理一次,页面重复打开。
  • 用户从登录页返回后,原始目标已丢失。

应为每个链接建立唯一处理记录,并让导航协调器串行消费。


9. 登录流程:目标暂存不等于绕过授权

Deep Link 登录通常包含两个不同问题:

  1. 用户是否已登录。
  2. 用户是否有权访问这个具体资源。

“已登录”不等于“有权访问订单 123”。所以正确流程应是:

收到 URI
  -> 解析为内部目标
  -> 判断是否需要登录
  -> 未登录:暂存目标,进入登录
  -> 登录成功:重新解析或校验目标
  -> 请求服务端确认权限
  -> 有权限才展示资源

暂存目标时,不要只保存未经校验的原始字符串:

// 不推荐:原始 URL 以后可能被当作任意跳转地址。
String? pendingUrl;

应保存结构化目标:

sealed class PendingDestination {
  const PendingDestination();
}

final class PendingOrder extends PendingDestination {
  const PendingOrder(this.orderId);

  final String orderId;
}

登录成功后:

Future<void> resumeAfterLogin(PendingDestination destination) async {
  switch (destination) {
    case PendingOrder(:final orderId):
      final allowed = await authorizationService.canReadOrder(orderId);

      if (!allowed) {
        showForbiddenPage();
        return;
      }

      openOrderPage(orderId);
  }
}

这里必须“登录后重新授权”,因为以下条件可能在登录期间变化:

  • 原链接来自未可信来源;
  • 用户登录的是另一个账号;
  • 资源权限发生变化;
  • 链接中的资源已删除;
  • 链接已经过期;
  • 应用进程被杀死后恢复了旧数据。

9.1 OAuth 回调与 Deep Link

移动端 OAuth 回调常见形式是:

myapp://auth/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE

或者使用经过验证的 HTTPS App Link/Universal Link:

https://example.com/auth/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE

推荐使用 Authorization Code + PKCE

  1. 应用生成高熵 code_verifier
  2. 应用计算 code_challenge 并发起授权。
  3. 应用生成随机 state,并保存本次登录上下文。
  4. 授权服务器回调应用,携带 codestate
  5. 应用比较回调中的 state 与本地保存值。
  6. 应用使用 code_verifier 兑换 Token。
  7. 服务器使授权码短期有效且只能使用一次。

形式上,回调能够被接受至少需要:

received_state == stored_state

并且:

authorization_code 未使用
authorization_code 未过期
PKCE(code_verifier) 与授权请求匹配
redirect_uri 与原授权请求一致

仅仅判断 code 不为空是不安全的。攻击者可以构造一个带有自己授权码的链接,如果应用没有校验 state,就可能发生登录 CSRF 或账号绑定错误。

不要把 Access Token 放在 Deep Link 中:

myapp://callback?access_token=...

原因包括:

  • URL 可能出现在系统日志、浏览器历史、分析 SDK 或错误报告中;
  • 自定义 Scheme 可能被其他应用抢先注册;
  • 复制、分享和截图会扩大泄露范围;
  • 前台链接监听器可能被错误记录。

授权码也应尽量短期、单次使用,并避免在普通日志中打印完整 URI。


10. 安全模型:平台验证不能替代业务验证

10.1 Universal Link 和 App Link 解决的是什么问题

域名关联文件解决的是:

“这个 HTTPS 域名允许哪个应用处理哪些链接?”

它不解决:

“用户是否登录?”
“用户是否有权查看订单?”
“订单 ID 是否存在?”
“参数是否过期?”
“这个操作是否允许执行?”

因此,即使 Universal Link 验证成功,也必须把所有链接输入当作不可信输入。

10.2 自定义 Scheme 的劫持风险

假设应用使用:

myapp://auth/callback

其他应用也可能声明相同的 myapp Scheme。系统的实际分发行为可能依赖安装顺序、默认选择和平台版本。对于普通分享链接,这种风险可能可以接受;对于登录回调和敏感操作,不应仅依赖自定义 Scheme。

可选策略是:

  • 优先使用 HTTPS Universal Link/App Link。
  • OAuth 使用 PKCE 和 state
  • 回调不携带长期 Token。
  • 服务端再次验证授权码、客户端、重定向地址和会话。
  • 对敏感操作要求用户在应用内确认。

10.3 Host、Path 和参数的白名单

不要只检查:

uri.scheme == 'https'

下面的 URL 仍然不是目标应用自己的链接:

https://attacker.example/orders/123

至少应检查:

final allowed =
    uri.scheme == 'https' &&
    uri.host.toLowerCase() == 'example.com' &&
    uri.port == 443;

如果允许多个 host,应使用显式集合:

const allowedHosts = {
  'example.com',
  'm.example.com',
};

bool isAllowedHost(Uri uri) {
  return uri.scheme == 'https' &&
      allowedHosts.contains(uri.host.toLowerCase()) &&
      (uri.port == 0 || uri.port == 443);
}

同时应限制路径前缀和参数名称。不要把 queryParameters 整体反序列化成任意命令对象,更不要允许 URI 直接决定要执行的系统操作。

10.4 URI 解析与规范化的边界

常见错误是先做宽松规范化,再做安全判断。例如把多个斜杠、点路径或大小写统一后,可能导致“验证的路径”和“实际导航的路径”不是同一个字符串。

安全策略应明确:

  1. 先使用平台和 Uri 的标准解析。
  2. 明确允许的 Scheme、Host、端口和路径结构。
  3. 对业务 ID 使用严格字符集。
  4. 将规范化后的结构作为唯一后续输入。
  5. 不再使用原始 URL 进行第二套解析。

如果服务端也会解析同一个 URL,客户端和服务端应对编码、尾部斜杠、大小写和重复参数建立一致约定,否则可能出现解析差异。


11. 处理重复链接、并发和进程生命周期

应用可能同时收到多个链接:

A: /orders/123
B: /orders/456

也可能因为生命周期回调重复收到同一个链接。导航系统需要定义策略,而不是依赖回调执行顺序。

一个简单的串行协调器如下:

import 'dart:async';
import 'dart:collection';

class DeepLinkCoordinator {
  final Queue<Uri> _queue = Queue<Uri>();
  bool _processing = false;
  String? _lastProcessedKey;

  Future<void> add(Uri uri) async {
    final key = uri.toString();

    // 只做短时间或单次去重时可以使用该策略。
    // 如果业务允许用户重复打开同一资源,不应永久去重。
    if (key == _lastProcessedKey) {
      return;
    }

    _queue.add(uri);
    await _drain();
  }

  Future<void> _drain() async {
    if (_processing) return;

    _processing = true;
    try {
      while (_queue.isNotEmpty) {
        final uri = _queue.removeFirst();
        _lastProcessedKey = uri.toString();

        final target = parseLink(uri);
        await _resolveAndNavigate(target);
      }
    } finally {
      _processing = false;
    }
  }

  Future<void> _resolveAndNavigate(LinkTarget target) async {
    switch (target) {
      case OrderTarget(:final id):
        // 等待会话恢复,再检查服务端权限,然后导航。
        print('resolve order: $id');
      case LoginCallbackTarget(:final code, :final state):
        // 先校验 state,再用 code + PKCE 换取会话。
        print('handle oauth callback: $code, $state');
      case HomeTarget():
        print('navigate home');
      case InvalidTarget(:final reason):
        print('show invalid link: $reason');
    }
  }
}

这个示例体现了三个状态约束:

  • _processing 保证同一时刻只有一个导航消费循环。
  • _queue 保留尚未处理的链接。
  • _lastProcessedKey 避免典型的重复回调。

但去重不能简单地永久按完整 URL 丢弃。用户可能确实需要再次打开同一订单;登录回调的去重又通常应该更严格。生产实现应按链接类型定义幂等性:

  • 订单查看:重复打开通常是幂等的。
  • 支付确认:必须使用服务端幂等键。
  • OAuth 授权码:只能兑换一次。
  • 删除或确认操作:不能因为收到 URI 就自动执行。

12. 失败路径与诊断顺序

12.1 点击后只打开网页

可能原因:

  • iOS 的 Associated Domains 没有启用。
  • AASA 文件中的 Team ID 或 Bundle ID 错误。
  • Android 的 assetlinks.json 签名指纹错误。
  • Manifest 没有 VIEWBROWSABLE 或匹配的 host/path。
  • 域名文件返回了重定向、登录页或无效 JSON。
  • 用户已经选择过在浏览器中打开。
  • 测试入口本身不触发系统链接分发。

诊断应从外到内:

  1. 用浏览器请求关联文件,确认实际响应内容。
  2. 检查应用包名、Bundle ID、签名和 Team ID。
  3. 检查平台配置是否与线上域名完全一致。
  4. 使用 Android ADB 或真实 iOS 点击测试。
  5. 确认应用是否真的收到了 URI。
  6. 最后检查 Flutter 解析器和路由状态。

12.2 应用打开但总是进入首页

说明系统分发可能已成功,但 Flutter 层没有消费链接。常见原因:

  • 只处理了初始链接,没有监听后续链接。
  • 只监听了后续流,没有读取冷启动链接。
  • 在 Router 初始化之前丢弃 URI。
  • 解析失败后静默回退首页。
  • 链接被插件和 Flutter 默认深链处理同时消费。
  • 首屏导航完成后又覆盖了目标状态。

诊断时记录结构化信息,而不是直接打印敏感 URI:

void logLink(Uri uri, LinkTarget target) {
  debugPrint(
    'deep_link '
    'scheme=${uri.scheme} '
    'host=${uri.host} '
    'path=${uri.path} '
    'target=${target.runtimeType}',
  );
}

不要在生产日志中打印 OAuth codeaccess_token、Cookie 或完整查询参数。

12.3 收到链接两次

常见来源是:

  • 初始链接 API 与链接流都返回同一个链接。
  • Android Activity 重新创建。
  • 页面组件重复订阅流。
  • 插件生命周期与 Flutter Router 同时处理 URI。

修复方法不是简单地“忽略第二次调用”,而是:

  1. 规定一个唯一的链接入口。
  2. 在应用级对象中订阅,而不是每个页面订阅。
  3. 对回调和导航任务做幂等处理。
  4. 明确冷启动、后台恢复和前台运行的测试矩阵。
  5. 在订阅处保存并取消 StreamSubscription

12.4 目标页闪退或空白

链接解析成功不代表资源加载成功。目标页还可能遇到:

  • 订单不存在;
  • 用户没有权限;
  • Token 过期;
  • 网络不可用;
  • 服务端返回 401、403、404 或 410;
  • 页面在登录完成前就读取了用户状态。

目标页面应区分至少这些结果:

401:需要登录或重新登录
403:已登录但无权访问
404:资源不存在
410:链接或资源已过期
网络错误:提供重试,不要误显示“无权限”

把所有异常都跳首页会让用户无法判断链接是否有效,也会掩盖配置和权限问题。


13. Android、iOS、桌面和 Web 的差异

Android

Android 接收外部链接通常以 Intent 为入口。应用可能被冷启动,也可能复用已有 Activity。Manifest 的 launchMode、Activity 重建和系统版本会影响链接传递方式。App Links 依赖域名与签名关联,但实际验证状态仍应在目标设备和目标构建版本上测试。

iOS

iOS Universal Link 依赖 Associated Domains、AASA 文件和系统的链接选择策略。它不是普通 URL Scheme 的别名,不能用 Android 的 assetlinks.json 配置替代。用户行为、系统缓存以及点击来源可能导致 Universal Link 最终进入 Safari。

桌面端

Flutter Desktop 的 Deep Link 支持通常不是移动端那种统一的系统行为:

  • Windows、macOS、Linux 的 Scheme 注册方式不同。
  • 应用可能收到命令行参数,而不是移动端生命周期回调。
  • 已运行实例如何把新链接转发给已有实例,需要平台代码或插件支持。
  • Universal Link 是 iOS 概念,不能直接套用到桌面端。

桌面端的应用层仍可复用 Uri 解析器和安全白名单,但平台接收层必须分别实现。

Web

Flutter Web 中,https://example.com/orders/123 首先是浏览器 URL。服务器必须正确处理深层路径:

  • 用户直接访问 /orders/123 时,服务器应返回 Flutter Web 的入口 HTML。
  • 浏览器 History API、服务器回退规则和静态资源路径必须协调。
  • 如果服务器只知道 /,直接刷新深层路径可能返回 404。
  • Web 端不能依赖移动端的 App Links 或 Universal Links。

Web 的路由地址本身暴露在地址栏、历史记录和日志中,因此同样不应在查询参数中放长期 Token。


14. 测试策略:不要只测试“能否打开首页”

一个完整测试矩阵至少包括:

场景 应验证的行为
应用未安装 HTTPS 链接打开网站
应用已安装且终止 应用冷启动并进入目标
应用在后台 恢复应用并处理目标
应用在前台 不重启应用,处理新目标
未登录 暂存目标并进入登录
登录成功 恢复目标并重新授权
登录取消 不误打开受保护页面
无权限 显示 403 类页面
资源不存在 显示 404 类页面
非法 Scheme/Host 拒绝处理
重复回调 不重复兑换授权码或重复导航
网络失败 显示可恢复错误和重试入口

示例链接也应覆盖边界:

https://example.com/orders/123
https://example.com/orders/
https://example.com/orders/a%2Fb
https://example.com/orders/123?from=email
https://example.com/orders/123#summary
https://attacker.example/orders/123
myapp://auth/callback?code=x&state=y

每个测试应记录三类结果:

  1. 系统把链接交给了哪个应用。
  2. Flutter 实际收到的 URI 是什么。
  3. 应用最终进入了哪个业务状态。

只有第三项正确,才算端到端成功。前两项正确而页面错误,属于 Flutter 路由或业务授权问题;第一项就错误,属于平台关联配置问题。


15. 生产取舍

Custom Scheme 配置简单,适合内部跳转、开发测试和不要求域名归属证明的场景,但存在 Scheme 冲突和劫持风险。

Android App Links 与 iOS Universal Links 使用 HTTPS,能够保留网站降级路径,并通过域名关联减少错误应用接管链接的风险;代价是需要维护签名、应用标识、关联文件、HTTPS 和多环境域名。

将 Deep Link 直接映射成页面路径最容易实现,但对登录、权限和并发不够安全。将链接先解析成结构化目标,再经过状态机、会话恢复和服务端授权,代码更多,却能清楚地区分:

链接有效性
登录状态
资源授权
导航状态
操作幂等性

最终,一个可靠的 Flutter Deep Link 系统应满足以下因果链:

平台正确识别链接
→ Flutter 在冷启动和运行中都收到链接
→ Uri 被严格解析为有限的内部目标
→ 目标经过登录与服务端授权
→ 路由状态以可恢复、可去重的方式更新
→ 失败被分类显示,而不是静默回首页

Universal Link 或 App Link 只负责这条链的第一步;安全的解析、登录恢复和最终页面行为,仍然必须由应用自己完整实现。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。