第三篇:从零实现 GitCode OAuth 登录 ------ 鸿蒙 6 应用实战
前三篇我们完成了项目搭建和新闻功能,这次实现一个完整的 OAuth 授权码登录流程------对接 GitCode 平台,让用户通过 WebView 登录授权、自动获取令牌、展示个人主页。
认证流程全景:
用户点击"GitCode 登录"
↓
WebView 打开 GitCode 授权页
↓
用户登录 → 授权
↓
GitCode 重定向回 app(携带授权码 code)
↓
WebView 拦截重定向 → 提取 code
↓
POST 请求交换 access_token
↓
保存令牌 → 跳转用户主页
一、OAuth 2.0 授权码模式
1.1 什么是 OAuth?
OAuth 2.0 是目前最主流的授权协议,让你可以用第三方账号登录自己的应用,而无需存储用户的密码。
1.2 授权码模式流程
| 步骤 | 方向 | 说明 |
|---|---|---|
| 1️⃣ | App → GitCode | 打开授权页面,用户登录并授权 |
| 2️⃣ | GitCode → App | 重定向回 App,携带 授权码 code |
| 3️⃣ | App → GitCode | 用 code + client_secret 交换 access_token |
| 4️⃣ | App → GitCode API | 用 access_token 获取用户信息 |
| 5️⃣ | GitCode → App | 返回用户资料(头像、用户名、仓库数等) |
关键安全机制:
client_secret只在后端/设备端使用,永不暴露给浏览器state参数防止 CSRF 攻击,确保回调是来自你发起的请求refresh_token用于令牌过期后无感刷新
二、鸿蒙 6 中的技术选型
在移动端实现 OAuth 有几种方式:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 系统浏览器 + 自定义 Scheme | 用 openLink 打开浏览器,通过 URL Scheme 回调 |
❌ 配置复杂,需注册 Scheme 到 module.json5 |
| WebView 内嵌 | 在 App 内嵌浏览器加载授权页,拦截重定向 | ✅ 完全可控,不需 Scheme 注册 |
| WebView + JS Bridge | 通过注入 JS 监听页面变化 | ❌ 过度设计 |
我们选择 WebView 内嵌方案,原因:
- 用户无需离开 App,体验更流畅
- 拦截重定向提取
code简单可靠 - 不依赖外部 Scheme 注册,减少配置
三、项目结构
text
entry/src/main/ets/
├── models/
│ └── OAuthModels.ets # OAuthToken、GitCodeUser 接口
├── service/
│ └── OAuthService.ets # 授权 URL 构建、令牌交换、用户信息获取
├── pages/
│ ├── Index.ets # 主页(新增登录按钮,检查登录状态)
│ ├── OAuthLogin.ets # WebView 授权页面
│ └── UserProfile.ets # 用户主页(展示资料、退出登录)
四、数据模型:OAuthModels.ets
定义两个核心数据结构和状态枚举:
typescript
// OAuth 令牌响应
export interface OAuthToken {
access_token: string;
expires_in: number; // 过期时间(秒),GitCode 为 1296000(15天)
refresh_token: string;
scope: string;
created_at: string;
}
// GitCode 用户信息
export interface GitCodeUser {
id: number;
login: string; // 用户名
name: string; // 显示名称
avatar_url: string; // 头像 URL
email: string;
bio: string; // 个人简介
followers: number;
following: number;
public_repos: number; // 公开仓库数
created_at: string;
company?: string;
blog?: string;
}
五、OAuth 服务层:OAuthService.ets
这是核心业务逻辑,封装了完整流程。
5.1 配置信息
typescript
const GITCODE_CLIENT_ID = '73863f25ecaa42bdac201d376c6264e9';
const GITCODE_CLIENT_SECRET = '3ca4fc2140014916b4430f854bcf8ecf';
const GITCODE_AUTH_URL = 'https://gitcode.com/oauth/authorize';
const GITCODE_TOKEN_URL = 'https://gitcode.com/oauth/token';
const GITCODE_API_BASE = 'https://api.gitcode.com/api/v5';
const REDIRECT_URI = 'https://haronyos.app/oauth/callback';
注意 :
client_secret存储在设备端代码中。生产环境应通过后端代理请求,避免硬编码。
5.2 构建授权 URL
typescript
export function buildAuthorizeUrl(): { url: string; state: string } {
const state = generateState(); // 16 位随机字符串,防 CSRF
const url = `${GITCODE_AUTH_URL}?client_id=${CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&response_type=code` +
`&scope=${encodeURIComponent(SCOPE)}` +
`&state=${state}`;
return { url, state };
}
参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
client_id |
73863f25... |
应用注册时获取的客户端 ID |
redirect_uri |
https://haronyos.app/oauth/callback |
授权后回调地址,在 WebView 中拦截 |
response_type |
code |
授权码模式固定值 |
scope |
all_user all_projects ... |
请求的权限范围 |
state |
随机 16 位字符串 | CSRF 防御令牌 |
5.3 令牌交换(核心)
typescript
export async function exchangeCodeForToken(code: string): Promise<OAuthToken | null> {
const url = `${GITCODE_TOKEN_URL}?grant_type=authorization_code` +
`&code=${encodeURIComponent(code)}` +
`&client_id=${GITCODE_CLIENT_ID}`;
const resp = await http.createHttp().request(url, {
method: http.RequestMethod.POST,
extraData: `client_secret=${encodeURIComponent(GITCODE_CLIENT_SECRET)}`,
header: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
});
const result = resp.result as Record<string, Object>;
// 提取 access_token, refresh_token, expires_in...
}
请求细节:
grant_type=authorization_code--- 固定值,表明使用授权码模式client_secret放在请求体中(form-data),而非 URL 查询参数- 返回的
access_token有效期 15 天 ,过期后可凭refresh_token续期
5.4 获取用户信息
typescript
export async function fetchCurrentUser(accessToken: string): Promise<GitCodeUser | null> {
const resp = await http.createHttp().request(`${API_BASE}/user`, {
method: http.RequestMethod.GET,
header: {
'Authorization': `Bearer ${accessToken}`, // 标准 Bearer Token 认证
'Accept': 'application/json',
},
});
// 解析返回的 JSON 为 GitCodeUser
}
5.5 刷新令牌
typescript
export async function refreshAccessToken(refreshToken: string): Promise<OAuthToken | null> {
const url = `${GITCODE_TOKEN_URL}?grant_type=refresh_token` +
`&refresh_token=${encodeURIComponent(refreshToken)}`;
// 返回包含新 access_token 的 OAuthToken
}
5.6 令牌持久化存储
使用 AppStorage 实现跨页面共享和持久化:
typescript
export function saveTokens(token: string, refreshToken: string): void {
AppStorage.set<string>('gitcode_access_token', token);
AppStorage.set<string>('gitcode_refresh_token', refreshToken);
}
export function loadTokens(): { token: string; refreshToken: string } | null {
const token = AppStorage.get<string>('gitcode_access_token');
const refreshToken = AppStorage.get<string>('gitcode_refresh_token');
if (token && refreshToken) return { token, refreshToken };
return null;
}
六、OAuth 登录页面:OAuthLogin.ets
这是最核心的页面,使用 @kit.ArkWeb 的 Web 组件。
6.1 页面初始化
typescript
import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct OAuthLogin {
private controller: webview.WebviewController = new webview.WebviewController();
private expectedState: string = '';
private readonly REDIRECT_PREFIX = 'https://haronyos.app/oauth/callback';
aboutToAppear(): void {
const { url, state } = buildAuthorizeUrl();
this.expectedState = state;
setTimeout(() => {
this.controller.loadUrl(url);
}, 100);
}
}
6.2 拦截重定向(关键逻辑)
typescript
onLoadIntercept(event: { data: WebResourceRequest }): boolean {
const reqUrl = event.data.getRequestUrl();
if (reqUrl && reqUrl.startsWith(this.REDIRECT_PREFIX)) {
this.handleRedirect(reqUrl);
return true; // 返回 true 阻止 WebView 继续加载
}
return false;
}
async handleRedirect(redirectUrl: string): Promise<void> {
// 1. 解析 URL 中的 code 和 state
const code = extractParam(redirectUrl, 'code');
const state = extractParam(redirectUrl, 'state');
// 2. 校验 state 防止 CSRF
if (state !== this.expectedState) {
this.errorMsg = '安全校验失败';
return;
}
// 3. 用 code 交换令牌
const token = await exchangeCodeForToken(code);
// 4. 保存令牌并跳转
saveTokens(token.access_token, token.refresh_token);
router.replaceUrl({ url: 'pages/UserProfile' });
}
为什么用 onLoadIntercept 而不是 onPageBegin?
因为 onLoadIntercept 在加载之前 触发,返回 true 可以阻止 WebView 加载重定向 URL(这个 URL 在浏览器中根本打不开)。而 onPageBegin 是加载之后才触发,页面已经白屏了。
6.3 WebView 组件声明
typescript
build() {
Column() {
// 顶部导航栏
Row() { /* 返回按钮 + 标题 */ }
// 加载指示器
if (this.isLoading) {
LoadingProgress()
}
// 错误提示
if (this.errorMsg) {
Text(this.errorMsg)
Button('重试')
}
// WebView
Web({ src: 'about:blank', controller: this.controller })
.onLoadIntercept((event) => this.onLoadIntercept(event))
.javaScriptAccess(true)
.domStorageAccess(true)
}
}
七、用户主页:UserProfile.ets
登录成功后展示用户信息,支持退出登录。
7.1 加载用户信息
typescript
async loadUserInfo(): Promise<void> {
const tokens = loadTokens();
if (!tokens) {
this.errorMsg = '未登录';
return;
}
this.user = await fetchCurrentUser(tokens.token);
}
7.2 个人信息卡片
┌─────────────────────────┐
│ ● (头像) │
│ 坚果的博客 (name) │
│ @NutPi (login) │
│ AtomCode 作者... (bio) │
├─────────────────────────┤
│ 📦 42 👥 128 ➡️ 56 │
│ 仓库 粉丝 关注 │
├─────────────────────────┤
│ 📧 email@example.com │
│ 🏢 AtomGit │
│ 🌐 blog.nutpi.net │
│ 📅 2024-04-20 │
├─────────────────────────┤
│ [ 退出登录 ] 🔴 │
└─────────────────────────┘
7.3 退出登录
typescript
handleLogout(): void {
clearTokens(); // 清空 AppStorage
router.replaceUrl({ url: 'pages/Index' });
}
八、首页按钮与登录状态
Index.ets 中的登录按钮会根据 AppStorage 中的令牌自动切换状态:
| 状态 | 按钮文本 | 按钮颜色 | 点击行为 |
|---|---|---|---|
| 未登录 | 🔑 GitCode 登录 | 黑色 | 跳转 OAuthLogin 页 |
| 已登录 | 🔑 GitCode 登录 | 绿色 | 跳转 UserProfile 页 |
typescript
aboutToAppear(): void {
const token = AppStorage.get<string>('gitcode_access_token');
this.hasLoggedIn = token !== undefined && token.length > 0;
}
九、路由与权限
9.1 路由注册
json
{
"src": [
"pages/Index",
"pages/News",
"pages/OAuthLogin", // ← 新增
"pages/UserProfile" // ← 新增
]
}
9.2 网络权限
OAuth 流程全部通过 HTTP API 完成,需要 INTERNET 权限(已在第一篇中添加):
json
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" }
]
十、完整数据流(时序图)
Index.ets OAuthLogin.ets GitCode OAuthService
│ │ │ │
│── 点击"GitCode 登录" │ │ │
│──→ router.pushUrl() ─────────→ │ │ │
│ │── buildAuthorizeUrl() ───→ │ │
│ │←── { url, state } ──────── │ │
│ │ │ │
│ │── WebView.loadUrl(url) ──→ │ │
│ │ │── 用户登录 + 授权 │
│ │ │── 重定向到 redirect_uri │
│ │←── { redirect_uri + code } ─│ │
│ │ │ │
│ │── onLoadIntercept() 拦截 │ │
│ │── 校验 state │ │
│ │── exchangeCodeForToken() ──→ │──→ POST /oauth/token │
│ │←── OAuthToken ───────────── │←── { access_token, ... } │
│ │ │ │
│ │── saveTokens() │ │
│ │── router.replaceUrl() │ │
│ │ │ │
│ UserProfile.ets │ │
│ │── fetchCurrentUser() ────── │──→ GET /api/v5/user │
│ │←── GitCodeUser ──────────── │←── { id, name, login, ... } │
│ │ │ │
│ │── 渲染用户主页 │ │
十一、总结
这次实现了一个完整的 OAuth 授权码流程,覆盖了:
| 功能 | 实现 | 难度 |
|---|---|---|
| 授权 URL 构建 | 拼接参数 + CSRF state | ⭐ |
| WebView 拦截回调 | onLoadIntercept 拦截 redirect URI |
⭐⭐ |
| 令牌交换 | POST 请求 + client_secret |
⭐⭐ |
| 用户信息获取 | Bearer Token 认证 | ⭐ |
| 令牌持久化 | AppStorage 存储 | ⭐ |
| 四态 UI(加载/成功/错误/未登录) | @State + if/else |
⭐⭐ |
| 退出登录 | 清空令牌 + 跳转首页 | ⭐ |
安全要点:
- ✅
state参数防 CSRF 攻击 - ✅
client_secret仅在设备端 POST 请求中使用 - ✅ 令牌不存储在明文文件中,使用 AppStorage(内存级安全)
- ✅
onLoadIntercept拦截,不暴露 redirect URI 到浏览器
bash
# 一句话用 AtomCode 实现:
atomcode "给鸿蒙 6 应用实现 GitCode OAuth 登录,WebView 加载授权页,拦截回调交换令牌"