请添加图片描述

认证方式概览

AtomGit 支持两种标准的 API 认证机制:

方式 认证 Header 适用场景 实现路径
OAuth2 授权码模式 Authorization: Bearer <token> 第三方应用标准登录 AuthService + OhosPlatform 浏览器授权
Personal Access Token Authorization: Bearer <token> 开发者快速接入 AuthProvider.setTokenFromManualInput

两种方式最终都使用 Authorization: Bearer 的 Header 格式,差异仅在于 Token 的获取路径。OAuth2 需要完整的浏览器跳转 → 用户授权 → 回调拦截 → 换取 Token 的流程,而 PAT 是用户在 AtomGit 网站手动生成后粘贴到应用中。

为什么最终采用 Token 优先策略

在项目初期,OAuth2 是默认的登录方案。但在实际开发和测试过程中暴露了几个问题:

  1. OAuth 应用注册门槛:每个开发者需要先在 AtomGit 创建自己的 OAuth 应用获取 Client ID 和 Client Secret。App 内置的密钥无法分发给普通用户。

  2. 回调 URL 限制:AtomGit 要求回调 URL 必须是 HTTPS 地址。自定义 URL Scheme(atomgit://oauth/callback)在开发阶段需要通过 HTTPS 重定向页中转,增加了部署复杂度。

  3. 调试困难:OAuth 流程涉及浏览器 → 服务器 → 原生回调 → Flutter 消息通道的多级跳转,任何一环出问题都难以定位。

基于这些现实因素,项目调整了策略:优先支持 Personal Access Token 直接输入(保证功能完整性),同时保留 OAuth 流程作为高级选项(提供更好的用户体验路径)。

OAuth2 授权码流程详解

OAuth2 授权码模式(Authorization Code Grant)是 AtomGit 官方推荐的第三方应用认证方式,适合运行在用户设备上的客户端应用。

第一步:应用注册

在 AtomGit 开发者设置中创建 OAuth 应用,配置以下信息:

  • 应用名称:AtomGit Flutter Client
  • 回调 URLatomgit://oauth/callback(HarmonyOS 自定义 URL Scheme)
  • 权限范围repo user(仓库读写 + 用户信息)

注册完成后获得 Client ID 和 Client Secret。

第二步:构建授权 URL

AuthService 负责构建完整的 OAuth 授权 URL:

class AuthService {
  String buildAuthorizeUrl({
    required String clientId,
    required String clientSecret,
  }) {
    final params = {
      'client_id': clientId,
      'redirect_uri': ApiConstants.redirectUri,
      'scope': ApiConstants.scope,
      'response_type': 'code',
    };

    final query = params.entries
        .map((e) =>
            '${Uri.encodeComponent(e.key)}='
            '${Uri.encodeComponent(e.value)}')
        .join('&');

    return '${ApiConstants.authorizeUrl}?$query';
  }
}

生成的 URL 示例:

https://atomgit.com/login/oauth/authorize
  ?client_id=40e71353714f41a4b1a7b48f054cb6fa
  &redirect_uri=atomgit%3A%2F%2Foauth%2Fcallback
  &scope=repo+user
  &response_type=code

每个参数都经过 Uri.encodeComponent 编码,保证特殊字符(如 URL Scheme 中的 ://)在 URL 中正确传递。

response_type=code 指定使用授权码模式(而非隐式模式)。AtomGit 在用户授权后返回一个临时的授权码(code),应用用这个 code 去交换 access_token。

OAuth 回调 URL 的演变

回调 URL 经历了三个阶段的调整:

阶段一:简化格式 atomgit://callback

  • 问题:AtomGit 的表单校验将其识别为非法 URL 格式

阶段二:标准 URI Scheme atomgit://oauth/callback

  • URL 三段式:scheme://host/path,通过了 AtomGit 的表单验证
  • 问题:AtomGit 进一步要求回调 URL 必须是 HTTPS 地址

阶段三:HTTPS 重定向中转

  • 在 GitHub Pages 上部署 oauth_callback.html
  • 该页面接收 ?code=xxx 参数,通过 JavaScript 重定向到 atomgit://oauth/callback?code=xxx
  • 最终在 AtomGit 注册的回调 URL 为 HTTPS 页面地址
  • HarmonyOS 系统识别到 atomgit:// scheme 后路由回应用
<!-- oauth_callback.html -->
<!DOCTYPE html>
<html>
<body>
<script>
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  if (code) {
    window.location.href = 'atomgit://oauth/callback?code=' + code;
  }
</script>
</body>
</html>

第三步:打开系统浏览器

通过 HarmonyOS 平台通道打开系统浏览器:

// Dart 端
Future<bool> openBrowser(String url) async {
  final message = jsonEncode({
    'method': 'openBrowser',
    'url': url,
  });
  final reply = await _channel!.send(message);
  if (reply != null) {
    final result = jsonDecode(reply) as Map<String, dynamic>;
    return result['success'] == true;
  }
  return false;
}
// ArkTS 端
private openBrowser(url: string): void {
  const want: Want = {
    action: 'ohos.want.action.viewData',
    uri: url
  };
  this.context.startAbility(want);
}

ohos.want.action.viewData 是 HarmonyOS 的通用数据查看动作,系统会根据 URI 的协议类型自动选择合适的应用打开(https:// 会打开系统浏览器)。

第四步:Native 拦截回调

当用户在浏览器中完成授权后,AtomGit 服务器会重定向到回调 URL。HarmonyOS 通过 URL Scheme 机制将控制权交回应用:

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  const uri: string | undefined = want?.uri;
  if (uri && uri.startsWith('atomgit://oauth/callback')) {
    const code: string | null = this.extractQueryParam(uri, 'code');
    if (code && this.authChannel) {
      const message = JSON.stringify({
        type: 'authCode',
        code: code
      });
      this.authChannel.send(message);
    }
  }
}

onNewWant 是 HarmonyOS Ability 的生命周期回调,在应用收到新的 Want(意图)时触发。EntryAbility 检查 URI 是否是 OAuth 回调,提取 code 参数,通过 MessageChannel 发送给 Dart 端。

URL 参数的手动解析

HarmonyOS 的 API 不提供类似 JavaScript URLSearchParams 的工具类,需要手动解析查询参数:

private extractQueryParam(uri: string, param: string): string | null {
  const queryIndex = uri.indexOf('?');
  if (queryIndex === -1) return null;

  const query = uri.substring(queryIndex + 1);
  const pairs = query.split('&');

  for (const pair of pairs) {
    const [key, value] = pair.split('=');
    if (key === param && value) {
      return decodeURIComponent(value);
    }
  }
  return null;
}

分步解析逻辑:

  1. 找到 ? 的位置,截取之后的查询字符串
  2. & 分割参数对
  3. 每对按 = 分割 key 和 value
  4. 对匹配 key 的 value 进行 decodeURIComponent 解码

第五步:收取授权码

Dart 端通过 Broadcast Stream 接收授权码:

class OhosPlatform {
  final _authCodeController = StreamController<String>.broadcast();
  Stream<String> get onAuthCode => _authCodeController.stream;

  void init() {
    _channel!.setMessageHandler((String? message) async {
      if (message != null) {
        final decoded = jsonDecode(message) as Map<String, dynamic>;
        if (decoded['type'] == 'authCode') {
          _authCodeController.add(decoded['code'] as String);
        }
      }
    });
  }
}

Broadcast Stream 允许多个监听者同时订阅。LoginScreen 在 initState 中订阅:

class _LoginScreenState extends State<LoginScreen> {
  StreamSubscription<String>? _authCodeSub;

  
  void initState() {
    super.initState();
    _authCodeSub = OhosPlatform.instance.onAuthCode.listen((code) {
      _handleAuthCode(code);
    });
  }

  
  void dispose() {
    _authCodeSub?.cancel();  // 必须取消订阅,防止内存泄漏
    super.dispose();
  }
}

第六步:换取 Access Token

用授权码(code)向 Token 端点换取 access_token:

Future<void> exchangeCode(
  String code, {
  String? clientId,
  String? clientSecret,
}) async {
  final response = await _httpClient.post(
    Uri.parse(ApiConstants.tokenUrl),
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    body: jsonEncode({
      'client_id': clientId ?? '',
      'client_secret': clientSecret ?? '',
      'code': code,
    }),
  );

  if (response.statusCode == 200) {
    final data = jsonDecode(response.body);
    final accessToken = data['access_token'] as String;

    // 1. 设置 API 客户端
    _apiClient.setAccessToken(accessToken);

    // 2. 持久化到本地
    await LocalStorage.instance.write('access_token', accessToken);

    // 3. 更新登录状态
    _accessToken = accessToken;
    _isLoggedIn = true;
    notifyListeners();
  }
}

关键细节:Token 交换的所有参数(client_id、client_secret、code)都放在 JSON Body 中,而非 Query String。这是 AtomGit API 的要求(与 GitHub 的 OAuth 实现不同,GitHub 接受 Query String 参数)。

第七步:Session 恢复

应用重启时,自动从本地存储恢复 Token:

Future<void> tryRestoreSession() async {
  final token =
      await LocalStorage.instance.read<String>('access_token');

  if (token != null && token.isNotEmpty) {
    _accessToken = token;
    _apiClient.setAccessToken(token);
    _isLoggedIn = true;
    notifyListeners();
  }
}

恢复时机在 App 启动初期——AtomGitApp 创建 AuthProvider 时自动调用 tryRestoreSession。如果本地存储中存在有效 Token,用户无需重新登录即可使用全部功能。

登录页面的三重入口设计

LoginScreen 提供三种登录路径,覆盖不同的使用场景:

1. OAuth 浏览器登录(主路径)

用户输入 Client ID 和 Client Secret → 点击浏览器登录 → 跳转系统浏览器 → 授权 → 自动回调 → 换取 Token:

Future<void> _startOAuth() async {
  final clientId = _clientIdController.text.trim();
  final clientSecret = _clientSecretController.text.trim();

  if (clientId.isEmpty || clientSecret.isEmpty) {
    _showError('请输入 Client ID 和 Client Secret');
    return;
  }

  setState(() => _isLoading = true);

  final authUrl = AuthService.buildAuthorizeUrl(
    clientId: clientId,
    clientSecret: clientSecret,
  );

  final success = await OhosPlatform.instance.openBrowser(authUrl);
  if (!success) {
    setState(() => _isLoading = false);
    _showError('无法打开浏览器');
  }
}

打开浏览器后,页面保持"等待授权中…"的 loading 状态。一旦收到 OAuth 回调(通过 onAuthCode Stream),自动调用 exchangeCode 完成登录。

2. 手动输入授权码(备用路径)

浏览器回调失败时(如网络导致 URL Scheme 未被拦截),用户可以手动从浏览器地址栏复制 code 参数粘贴:

TextField(
  controller: _codeController,
  decoration: const InputDecoration(
    hintText: '粘贴浏览器返回的 ?code= 参数',
  ),
),
OutlinedButton(
  onPressed: () {
    final code = _codeController.text.trim();
    if (code.isNotEmpty) {
      // 手动输入时必须提供 client_id 和 client_secret
      context.read<AuthProvider>().exchangeCode(
        code,
        clientId: _clientIdController.text.trim(),
        clientSecret: _clientSecretController.text.trim(),
      );
    }
  },
  child: const Text('提交授权码'),
),

3. Personal Access Token 直接登录(最快路径)

用户在 AtomGit 网站生成 PAT → 粘贴 → 即时登录:

TextField(
  controller: _tokenController,
  decoration: const InputDecoration(
    hintText: '粘贴 Personal Access Token',
  ),
),
FilledButton(
  onPressed: () {
    final token = _tokenController.text.trim();
    if (token.isNotEmpty) {
      context
          .read<AuthProvider>()
          .setTokenFromManualInput(token);
      Navigator.pop(context);  // 返回上一页
    }
  },
  child: const Text('使用 Token 登录'),
),

Token 登录的代码路径最短:直接调用 setTokenFromManualInput → 设置 API 客户端 → 持久化 → 通知 UI → 关闭登录页。相比 OAuth 流程少了浏览器跳转和 Token 交换两个步骤。

AuthProvider 的完整设计

class AuthProvider extends ChangeNotifier {
  final AtomGitApiClient _apiClient;
  String? _accessToken;
  bool _isLoggedIn = false;

  bool get isLoggedIn => _isLoggedIn;

  // Token 直接登录
  Future<void> setTokenFromManualInput(String token) async {
    _accessToken = token;
    _apiClient.setAccessToken(token);
    await LocalStorage.instance.write('access_token', token);
    _isLoggedIn = true;
    notifyListeners();
  }

  // OAuth 换取 Token
  Future<void> exchangeCode(
    String code, {
    String? clientId,
    String? clientSecret,
  }) async {
    try {
      final response = await http.post(
        Uri.parse(ApiConstants.tokenUrl),
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
        },
        body: jsonEncode({
          'client_id': clientId ?? '',
          'client_secret': clientSecret ?? '',
          'code': code,
        }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        final token = data['access_token'] as String;
        _accessToken = token;
        _apiClient.setAccessToken(token);
        await LocalStorage.instance.write('access_token', token);
        _isLoggedIn = true;
        notifyListeners();
      }
    } on Exception catch (e) {
      // 错误处理
    }
  }

  // 登出
  Future<void> logout() async {
    _accessToken = null;
    _isLoggedIn = false;
    _apiClient.setAccessToken(null);
    await LocalStorage.instance.delete('access_token');
    notifyListeners();
  }
}

关键设计:AuthProvider 持有 AtomGitApiClient 的引用,每次 Token 变更(登录、登出、恢复)都同步调用 _apiClient.setAccessToken()。这保证了认证状态与 API 调用的一致性——用户登录后,所有后续 API 请求自动携带 Bearer Token;用户登出后,API 客户端立即清除认证信息,不会再发送已失效的 Token。

API 认证 Header

AtomGit API 的认证需要通过 HTTP Header 传递:

Map<String, String> get _headers => {
  'Accept': 'application/json',
  'Content-Type': 'application/json',
  'X-Api-Version': '2023-02-21',
  if (_accessToken != null)
    'Authorization': 'Bearer $_accessToken',
};

四个 Header:

  • Accept: application/json — 期望服务器返回 JSON
  • Content-Type: application/json — 请求体是 JSON(POST 请求时)
  • X-Api-Version: 2023-02-21 — AtomGit API 版本标识(必需)
  • Authorization: Bearer <token> — 认证凭证(条件性,仅登录后携带)

if (_accessToken != null) 是 Dart 的集合条件语法——只在 Token 非空时添加 Authorization Header。未登录时 API 请求不带认证信息,只能访问公开端点。

登录状态传播机制

AuthProvider 的 notifyListeners() 会触发所有使用 context.watch<AuthProvider>() 的 Widget 重建。这驱动了整个应用的 UI 变化:

AuthProvider.notifyListeners()
  ├── MainShell: 无需变化(只是容器)
  ├── HomeTab: 从欢迎页切换到仓库列表
  ├── ExploreTab: 显示搜索功能
  ├── NotificationsTab: 从登录引导切换到通知占位
  ├── ProfileTab: 创建 UserProvider → 加载用户数据
  ├── SettingsScreen: 显示已登录状态 + 退出按钮
  └── 所有需要 auth 的页面: 可以正常发起 API 请求

这种一对多的广播机制使得添加新的登录感知组件非常简单——只需在 build 方法中 context.watch<AuthProvider>() 即可自动响应登录/登出事件。

OAuth 与 PAT 的技术比较

维度 OAuth2 授权码 Personal Access Token
用户体验 浏览器授权,需跳转 复制粘贴,需手动生成
实现复杂度 6 步流程,涉及平台通道 2 步:输入 + 验证
安全性 授权码一次性,短有效期 Token 长期有效,需妥善保管
Token 粒度 按 scope 控制权限 Token 创建时指定权限
调试难度 高(多级跳转) 低(直接输入)
适用场景 生产环境 开发/测试

两种方式在项目中并存:PAT 保证快速可用,OAuth 提供更好的标准化体验路径。

登出的完整清理

登出操作需要清理四层状态:

Future<void> logout() async {
  // 1. 清除内存中的 Token
  _accessToken = null;

  // 2. 更新登录标志
  _isLoggedIn = false;

  // 3. 清除 API 客户端的认证信息
  _apiClient.setAccessToken(null);

  // 4. 删除本地持久化的 Token
  await LocalStorage.instance.delete('access_token');

  // 5. 通知所有监听者
  notifyListeners();
}

任何一步遗漏都会导致状态不一致。例如:如果只清除 _isLoggedIn 而不调用 _apiClient.setAccessToken(null),UI 会显示未登录状态,但 API 请求仍然携带旧 Token(可能导致 401 错误或对已登出用户的数据泄露)。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐