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

认证方式概览
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 是默认的登录方案。但在实际开发和测试过程中暴露了几个问题:
-
OAuth 应用注册门槛:每个开发者需要先在 AtomGit 创建自己的 OAuth 应用获取 Client ID 和 Client Secret。App 内置的密钥无法分发给普通用户。
-
回调 URL 限制:AtomGit 要求回调 URL 必须是 HTTPS 地址。自定义 URL Scheme(
atomgit://oauth/callback)在开发阶段需要通过 HTTPS 重定向页中转,增加了部署复杂度。 -
调试困难:OAuth 流程涉及浏览器 → 服务器 → 原生回调 → Flutter 消息通道的多级跳转,任何一环出问题都难以定位。
基于这些现实因素,项目调整了策略:优先支持 Personal Access Token 直接输入(保证功能完整性),同时保留 OAuth 流程作为高级选项(提供更好的用户体验路径)。
OAuth2 授权码流程详解
OAuth2 授权码模式(Authorization Code Grant)是 AtomGit 官方推荐的第三方应用认证方式,适合运行在用户设备上的客户端应用。
第一步:应用注册
在 AtomGit 开发者设置中创建 OAuth 应用,配置以下信息:
- 应用名称:AtomGit Flutter Client
- 回调 URL:
atomgit://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;
}
分步解析逻辑:
- 找到
?的位置,截取之后的查询字符串 - 按
&分割参数对 - 每对按
=分割 key 和 value - 对匹配 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— 期望服务器返回 JSONContent-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 错误或对已登出用户的数据泄露)。
更多推荐

所有评论(0)