AtomGit Flutter鸿蒙客户端:OAuth2认证与登录
认证方式概览
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:
classAuthService{StringbuildAuthorizeUrl({requiredStringclientId,requiredStringclientSecret,}){finalparams={'client_id':clientId,'redirect_uri':ApiConstants.redirectUri,'scope':ApiConstants.scope,'response_type':'code',};finalquery=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 Schemeatomgit://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 --><!DOCTYPEhtml><html><body><script>constparams=newURLSearchParams(window.location.search);constcode=params.get('code');if(code){window.location.href='atomgit://oauth/callback?code='+code;}</script></body></html>第三步:打开系统浏览器
通过 HarmonyOS 平台通道打开系统浏览器:
// Dart 端Future<bool>openBrowser(Stringurl)async{finalmessage=jsonEncode({'method':'openBrowser','url':url,});finalreply=await_channel!.send(message);if(reply!=null){finalresult=jsonDecode(reply)asMap<String,dynamic>;returnresult['success']==true;}returnfalse;}// ArkTS 端privateopenBrowser(url:string):void{constwant: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{consturi:string|undefined=want?.uri;if(uri&&uri.startsWith('atomgit://oauth/callback')){constcode:string|null=this.extractQueryParam(uri,'code');if(code&&this.authChannel){constmessage=JSON.stringify({type:'authCode',code:code});this.authChannel.send(message);}}}onNewWant是 HarmonyOS Ability 的生命周期回调,在应用收到新的 Want(意图)时触发。EntryAbility 检查 URI 是否是 OAuth 回调,提取code参数,通过 MessageChannel 发送给 Dart 端。
URL 参数的手动解析
HarmonyOS 的 API 不提供类似 JavaScriptURLSearchParams的工具类,需要手动解析查询参数:
privateextractQueryParam(uri:string,param:string):string|null{constqueryIndex=uri.indexOf('?');if(queryIndex===-1)returnnull;constquery=uri.substring(queryIndex+1);constpairs=query.split('&');for(constpairofpairs){const[key,value]=pair.split('=');if(key===param&&value){returndecodeURIComponent(value);}}returnnull;}分步解析逻辑:
- 找到
?的位置,截取之后的查询字符串 - 按
&分割参数对 - 每对按
=分割 key 和 value - 对匹配 key 的 value 进行
decodeURIComponent解码
第五步:收取授权码
Dart 端通过 Broadcast Stream 接收授权码:
classOhosPlatform{final_authCodeController=StreamController<String>.broadcast();Stream<String>getonAuthCode=>_authCodeController.stream;voidinit(){_channel!.setMessageHandler((String?message)async{if(message!=null){finaldecoded=jsonDecode(message)asMap<String,dynamic>;if(decoded['type']=='authCode'){_authCodeController.add(decoded['code']asString);}}});}}Broadcast Stream 允许多个监听者同时订阅。LoginScreen 在initState中订阅:
class_LoginScreenStateextendsState<LoginScreen>{StreamSubscription<String>?_authCodeSub;@overridevoidinitState(){super.initState();_authCodeSub=OhosPlatform.instance.onAuthCode.listen((code){_handleAuthCode(code);});}@overridevoiddispose(){_authCodeSub?.cancel();// 必须取消订阅,防止内存泄漏super.dispose();}}第六步:换取 Access Token
用授权码(code)向 Token 端点换取 access_token:
Future<void>exchangeCode(Stringcode,{String?clientId,String?clientSecret,})async{finalresponse=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){finaldata=jsonDecode(response.body);finalaccessToken=data['access_token']asString;// 1. 设置 API 客户端_apiClient.setAccessToken(accessToken);// 2. 持久化到本地awaitLocalStorage.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{finaltoken=awaitLocalStorage.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{finalclientId=_clientIdController.text.trim();finalclientSecret=_clientSecretController.text.trim();if(clientId.isEmpty||clientSecret.isEmpty){_showError('请输入 Client ID 和 Client Secret');return;}setState(()=>_isLoading=true);finalauthUrl=AuthService.buildAuthorizeUrl(clientId:clientId,clientSecret:clientSecret,);finalsuccess=awaitOhosPlatform.instance.openBrowser(authUrl);if(!success){setState(()=>_isLoading=false);_showError('无法打开浏览器');}}打开浏览器后,页面保持"等待授权中…"的 loading 状态。一旦收到 OAuth 回调(通过onAuthCodeStream),自动调用exchangeCode完成登录。
2. 手动输入授权码(备用路径)
浏览器回调失败时(如网络导致 URL Scheme 未被拦截),用户可以手动从浏览器地址栏复制code参数粘贴:
TextField(controller:_codeController,decoration:constInputDecoration(hintText:'粘贴浏览器返回的 ?code= 参数',),),OutlinedButton(onPressed:(){finalcode=_codeController.text.trim();if(code.isNotEmpty){// 手动输入时必须提供 client_id 和 client_secretcontext.read<AuthProvider>().exchangeCode(code,clientId:_clientIdController.text.trim(),clientSecret:_clientSecretController.text.trim(),);}},child:constText('提交授权码'),),3. Personal Access Token 直接登录(最快路径)
用户在 AtomGit 网站生成 PAT → 粘贴 → 即时登录:
TextField(controller:_tokenController,decoration:constInputDecoration(hintText:'粘贴 Personal Access Token',),),FilledButton(onPressed:(){finaltoken=_tokenController.text.trim();if(token.isNotEmpty){context.read<AuthProvider>().setTokenFromManualInput(token);Navigator.pop(context);// 返回上一页}},child:constText('使用 Token 登录'),),Token 登录的代码路径最短:直接调用setTokenFromManualInput→ 设置 API 客户端 → 持久化 → 通知 UI → 关闭登录页。相比 OAuth 流程少了浏览器跳转和 Token 交换两个步骤。
AuthProvider 的完整设计
classAuthProviderextendsChangeNotifier{finalAtomGitApiClient_apiClient;String?_accessToken;bool _isLoggedIn=false;boolgetisLoggedIn=>_isLoggedIn;// Token 直接登录Future<void>setTokenFromManualInput(Stringtoken)async{_accessToken=token;_apiClient.setAccessToken(token);awaitLocalStorage.instance.write('access_token',token);_isLoggedIn=true;notifyListeners();}// OAuth 换取 TokenFuture<void>exchangeCode(Stringcode,{String?clientId,String?clientSecret,})async{try{finalresponse=awaithttp.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){finaldata=jsonDecode(response.body);finaltoken=data['access_token']asString;_accessToken=token;_apiClient.setAccessToken(token);awaitLocalStorage.instance.write('access_token',token);_isLoggedIn=true;notifyListeners();}}onExceptioncatch(e){// 错误处理}}// 登出Future<void>logout()async{_accessToken=null;_isLoggedIn=false;_apiClient.setAccessToken(null);awaitLocalStorage.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. 删除本地持久化的 TokenawaitLocalStorage.instance.delete('access_token');// 5. 通知所有监听者notifyListeners();}任何一步遗漏都会导致状态不一致。例如:如果只清除_isLoggedIn而不调用_apiClient.setAccessToken(null),UI 会显示未登录状态,但 API 请求仍然携带旧 Token(可能导致 401 错误或对已登出用户的数据泄露)。
