HarmonyOS APP开发中的OAuth认证:OAuth 2.0 授权码流程与安全实践

举报
Jack20 发表于 2026/06/20 18:31:33 2026/06/20
【摘要】 HarmonyOS APP开发中的OAuth认证:OAuth 2.0 授权码流程与安全实践📌 核心要点:OAuth 2.0 是现代身份认证的基石,掌握授权码流程、PKCE 增强和令牌管理才能构建安全可靠的认证体系 一、背景与动机你有没有这样的经历——打开一个新 App,它提供"微信登录"“支付宝登录”"华为账号登录"的选项,点击后跳转到对应 App,确认授权后自动登录成功?你不需要在新 ...

HarmonyOS APP开发中的OAuth认证:OAuth 2.0 授权码流程与安全实践

📌 核心要点:OAuth 2.0 是现代身份认证的基石,掌握授权码流程、PKCE 增强和令牌管理才能构建安全可靠的认证体系


一、背景与动机

你有没有这样的经历——打开一个新 App,它提供"微信登录"“支付宝登录”"华为账号登录"的选项,点击后跳转到对应 App,确认授权后自动登录成功?你不需要在新 App 里注册账号,不需要记新密码,甚至不需要输入手机号。这就是 OAuth 2.0 的魔力。

OAuth 2.0 的核心思想很简单:让用户自己决定是否把信息分享给第三方,而第三方永远拿不到用户的密码。就像你入住酒店,前台给你一张房卡而不是万能钥匙——房卡只能开你那间房,而且有有效期。

但在 HarmonyOS 上实现 OAuth 2.0 并不是一件简单的事。移动端的 OAuth 和 Web 端有很大差异:没有浏览器 Cookie,没有 redirect_uri 回调,取而代之的是应用间跳转和深度链接。加上 PKCE 安全增强、多端适配和令牌安全存储,整个流程比想象中复杂得多。


二、核心原理

2.1 OAuth 2.0 授权码流程

OAuth 2.0 有多种授权模式,移动端最常用的是授权码模式(Authorization Code),因为它最安全——授权码只能用一次,且需要客户端密钥才能换取令牌。

sequenceDiagram
    participant User as 用户
    participant Client as 客户端App
    participant Auth as 授权服务器
    participant Resource as 资源服务器

    User->>Client: 1. 点击"第三方登录"
    Client->>Client: 2. 生成code_verifier和code_challenge(PKCE)
    Client->>Auth: 3. 打开授权页面(附带code_challenge)
    Auth->>User: 4. 显示授权确认页面
    User->>Auth: 5. 确认授权
    Auth->>Client: 6. 回调返回授权码(code)
    Client->>Auth: 7. 用code+code_verifier换取令牌
    Auth->>Auth: 8. 验证code_verifier匹配code_challenge
    Auth->>Client: 9. 返回access_token和refresh_token
    Client->>Resource: 10. 用access_token请求资源
    Resource->>Client: 11. 返回用户数据

    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff

2.2 PKCE 增强安全

PKCE(Proof Key for Code Exchange)是授权码模式的安全增强,专门为无法安全存储客户端密钥的移动端设计。它的核心思想是:

  1. 客户端生成一个随机的 code_verifier
  2. code_verifier 做 SHA-256 哈希得到 code_challenge
  3. 授权请求时发送 code_challenge
  4. 换取令牌时发送原始的 code_verifier
  5. 服务器验证 code_verifier 的哈希是否等于 code_challenge

这样即使授权码被截获,没有 code_verifier 也无法换取令牌。就像你寄快递时,寄件人和收件人各持有一半密码,只有两半拼在一起才能取件。

2.3 令牌生命周期

flowchart LR
    A[授权码<br/>code] -->|一次性使用| B[访问令牌<br/>access_token]
    B -->|过期| C{是否可刷新?}
    C -->|| D[刷新令牌<br/>refresh_token]
    D -->|换取新令牌| B
    C -->|| E[重新授权]
    B -->|有效| F[访问受保护资源]
    D -->|过期或撤销| E

    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff

    class A info
    class B primary
    class C warning
    class D purple
    class E error
    class F primary
令牌类型 有效期 用途 安全等级
授权码(code) 10分钟,一次性 换取访问令牌 高(用后即废)
访问令牌(access_token) 1-2小时 访问受保护资源 中(短有效期)
刷新令牌(refresh_token) 30-90天 换取新的访问令牌 高(长期有效)
ID令牌(id_token) 1小时 身份验证(OIDC) 中(JWT格式)

三、代码实战

3.1 完整的 OAuth 2.0 授权码 + PKCE 流程

这是移动端 OAuth 最核心的代码实现,包含 PKCE 生成、授权请求、令牌交换的完整流程。

import { util } from '@kit.ArkTS';
import { http } from '@kit.NetworkKit';
import { appAccount } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';

/**
 * OAuth 2.0 授权码 + PKCE 认证管理器
 * 实现完整的 OAuth 2.0 授权流程
 */
class OAuth2AuthManager {
  // OAuth 配置
  private config: OAuth2Config;

  // PKCE 参数
  private codeVerifier: string = '';
  private codeChallenge: string = '';

  // 令牌存储
  private tokenStore: TokenStore;

  constructor(config: OAuth2Config) {
    this.config = config;
    this.tokenStore = new TokenStore();
  }

  /**
   * 生成 PKCE 参数
   * code_verifier: 43-128位的随机字符串
   * code_challenge: code_verifier 的 SHA-256 哈希值(Base64URL编码)
   */
  generatePKCE(): { codeVerifier: string; codeChallenge: string } {
    // 生成随机的 code_verifier(43-128位)
    const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
    const length = 64; // 推荐长度
    let verifier = '';
    const randomValues = new Uint8Array(length);
    for (let i = 0; i < length; i++) {
      randomValues[i] = Math.floor(Math.random() * charset.length);
      verifier += charset.charAt(randomValues[i]);
    }

    // 计算 code_challenge = BASE64URL(SHA256(code_verifier))
    const hashUtil = util.createHash('SHA256');
    hashUtil.update(verifier);
    const hashResult = hashUtil.digest();
    const codeChallenge = util.Base64Helper.encodeToStringSync(hashResult,
      util.Base64Helper.BrowserURI_SAFE_ALPHABET);

    this.codeVerifier = verifier;
    this.codeChallenge = codeChallenge;

    console.info('[OAuth2] PKCE参数已生成');
    console.info(`[OAuth2] code_verifier长度: ${verifier.length}`);
    console.info(`[OAuth2] code_challenge长度: ${codeChallenge.length}`);

    return { codeVerifier: verifier, codeChallenge: codeChallenge };
  }

  /**
   * 构建授权请求URL
   * 用户将在此URL上完成授权确认
   */
  buildAuthorizationUrl(state: string): string {
    const pkce = this.generatePKCE();

    const params = new Map<string, string>([
      ['response_type', 'code'],
      ['client_id', this.config.clientId],
      ['redirect_uri', this.config.redirectUri],
      ['scope', this.config.scopes.join(' ')],
      ['state', state],
      ['code_challenge', pkce.codeChallenge],
      ['code_challenge_method', 'S256'],
    ]);

    // 拼接URL参数
    const queryString = Array.from(params.entries())
      .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
      .join('&');

    const authUrl = `${this.config.authorizationEndpoint}?${queryString}`;
    console.info(`[OAuth2] 授权URL已构建: ${authUrl.substring(0, 100)}...`);

    return authUrl;
  }

  /**
   * 使用授权码换取令牌
   * @param code 授权码
   */
  async exchangeToken(code: string): Promise<TokenResponse | null> {
    try {
      // 构建令牌请求
      const requestBody = new Map<string, string>([
        ['grant_type', 'authorization_code'],
        ['code', code],
        ['redirect_uri', this.config.redirectUri],
        ['client_id', this.config.clientId],
        ['code_verifier', this.codeVerifier], // PKCE 验证
      ]);

      const bodyString = Array.from(requestBody.entries())
        .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
        .join('&');

      // 发送令牌请求
      const httpRequest = http.createHttp();
      const response = await httpRequest.request(
        this.config.tokenEndpoint,
        {
          method: http.RequestMethod.POST,
          header: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Accept': 'application/json',
          },
          extraData: bodyString,
        }
      );

      if (response.responseCode === 200) {
        const tokenData = JSON.parse(response.result as string) as TokenResponse;
        console.info('[OAuth2] 令牌获取成功');

        // 安全存储令牌
        await this.tokenStore.saveTokens(tokenData);

        return tokenData;
      } else {
        console.error(`[OAuth2] 令牌请求失败: ${response.responseCode}`);
        return null;
      }
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[OAuth2] 令牌交换异常: ${err.message}`);
      return null;
    }
  }

  /**
   * 启动完整的OAuth授权流程
   */
  async startAuthorization(): Promise<TokenResponse | null> {
    try {
      // 1. 生成state参数(防止CSRF攻击)
      const state = this.generateRandomState();

      // 2. 构建授权URL
      const authUrl = this.buildAuthorizationUrl(state);

      // 3. 打开授权页面(通过应用间跳转)
      // 在HarmonyOS中,通常通过Ability跳转实现
      console.info('[OAuth2] 正在打开授权页面...');

      // 4. 用户完成授权后,通过回调获取授权码
      // 这部分需要在Ability的onCreate/onNewWant中处理
      // 详见3.2节的回调处理代码

      return null; // 实际令牌在回调中获取
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[OAuth2] 授权流程启动失败: ${err.message}`);
      return null;
    }
  }

  /**
   * 生成随机state参数
   * 用于防止CSRF攻击
   */
  private generateRandomState(): string {
    const length = 32;
    const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let state = '';
    for (let i = 0; i < length; i++) {
      state += charset.charAt(Math.floor(Math.random() * charset.length));
    }
    return state;
  }
}

/**
 * OAuth 2.0 配置接口
 */
interface OAuth2Config {
  clientId: string;               // 客户端ID
  clientSecret?: string;          // 客户端密钥(PKCE模式下不需要)
  redirectUri: string;            // 重定向URI
  scopes: string[];               // 请求的权限范围
  authorizationEndpoint: string;  // 授权端点
  tokenEndpoint: string;          // 令牌端点
  revocationEndpoint?: string;    // 撤销端点
}

/**
 * 令牌响应接口
 */
interface TokenResponse {
  access_token: string;           // 访问令牌
  token_type: string;             // 令牌类型(通常为Bearer)
  expires_in: number;             // 过期时间(秒)
  refresh_token?: string;         // 刷新令牌
  scope?: string;                 // 实际授予的权限范围
  id_token?: string;              // ID令牌(OIDC)
}

// 使用示例
async function demoOAuth2Flow() {
  const config: OAuth2Config = {
    clientId: 'com.example.myapp',
    redirectUri: 'hwauth://com.example.myapp/callback',
    scopes: ['openid', 'profile', 'email'],
    authorizationEndpoint: 'https://oauth.huawei.com/authorize',
    tokenEndpoint: 'https://oauth.huawei.com/token',
  };

  const authManager = new OAuth2AuthManager(config);
  await authManager.startAuthorization();
}

3.2 Token 刷新机制

访问令牌有效期很短(通常1-2小时),过期后需要使用刷新令牌获取新的访问令牌,而不需要用户重新授权。

import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';

/**
 * 令牌安全存储管理器
 * 使用HUKS加密存储令牌,防止令牌泄露
 */
class TokenStore {
  private readonly TOKEN_KEY_PREFIX = 'oauth_token_';

  /**
   * 安全存储令牌
   * @param tokens 令牌响应数据
   */
  async saveTokens(tokens: TokenResponse): Promise<void> {
    try {
      // 计算令牌过期时间戳
      const expiresAt = Date.now() + tokens.expires_in * 1000;

      const tokenData = {
        accessToken: tokens.access_token,
        refreshToken: tokens.refresh_token ?? '',
        tokenType: tokens.token_type,
        scope: tokens.scope ?? '',
        expiresAt: expiresAt,
        idToken: tokens.id_token ?? '',
      };

      // 在实际项目中,应使用HUKS加密后存储
      // 这里使用Preferences做简化演示
      console.info('[TokenStore] 令牌已安全存储');
      console.info(`[TokenStore] 过期时间: ${new Date(expiresAt).toLocaleString()}`);
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[TokenStore] 存储令牌失败: ${err.message}`);
    }
  }

  /**
   * 检查访问令牌是否即将过期
   * @param bufferSeconds 提前多少秒视为即将过期
   */
  isTokenExpiring(expiresAt: number, bufferSeconds: number = 300): boolean {
    const now = Date.now();
    const remaining = expiresAt - now;
    return remaining < bufferSeconds * 1000;
  }

  /**
   * 清除所有存储的令牌
   */
  async clearTokens(): Promise<void> {
    console.info('[TokenStore] 令牌已清除');
  }
}

/**
 * 令牌刷新管理器
 * 自动检测令牌过期并刷新
 */
class TokenRefreshManager {
  private config: OAuth2Config;
  private tokenStore: TokenStore;
  private refreshPromise: Promise<TokenResponse | null> | null = null;

  // 当前存储的令牌数据
  private currentTokens: TokenResponse | null = null;
  private expiresAt: number = 0;

  constructor(config: OAuth2Config) {
    this.config = config;
    this.tokenStore = new TokenStore();
  }

  /**
   * 获取有效的访问令牌
   * 如果令牌即将过期,自动刷新
   */
  async getValidAccessToken(): Promise<string | null> {
    // 检查是否有令牌
    if (!this.currentTokens) {
      console.error('[TokenRefresh] 没有可用的令牌,请先完成授权');
      return null;
    }

    // 检查令牌是否即将过期
    if (this.tokenStore.isTokenExpiring(this.expiresAt)) {
      console.info('[TokenRefresh] 令牌即将过期,正在刷新...');

      // 防止并发刷新
      if (!this.refreshPromise) {
        this.refreshPromise = this.refreshAccessToken();
      }

      const newTokens = await this.refreshPromise;
      this.refreshPromise = null;

      if (!newTokens) {
        console.error('[TokenRefresh] 令牌刷新失败');
        return null;
      }
    }

    return this.currentTokens.access_token;
  }

  /**
   * 使用刷新令牌获取新的访问令牌
   */
  private async refreshAccessToken(): Promise<TokenResponse | null> {
    try {
      if (!this.currentTokens?.refresh_token) {
        console.error('[TokenRefresh] 没有刷新令牌,需要重新授权');
        return null;
      }

      // 构建刷新请求
      const requestBody = new Map<string, string>([
        ['grant_type', 'refresh_token'],
        ['refresh_token', this.currentTokens.refresh_token],
        ['client_id', this.config.clientId],
      ]);

      const bodyString = Array.from(requestBody.entries())
        .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
        .join('&');

      // 发送刷新请求
      const httpRequest = http.createHttp();
      const response = await httpRequest.request(
        this.config.tokenEndpoint,
        {
          method: http.RequestMethod.POST,
          header: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Accept': 'application/json',
          },
          extraData: bodyString,
        }
      );

      if (response.responseCode === 200) {
        const newTokens = JSON.parse(response.result as string) as TokenResponse;
        console.info('[TokenRefresh] 令牌刷新成功');

        // 更新存储的令牌
        this.currentTokens = newTokens;
        this.expiresAt = Date.now() + newTokens.expires_in * 1000;
        await this.tokenStore.saveTokens(newTokens);

        return newTokens;
      } else if (response.responseCode === 400 || response.responseCode === 401) {
        // 刷新令牌无效,需要重新授权
        console.error('[TokenRefresh] 刷新令牌已失效,需要重新授权');
        this.currentTokens = null;
        return null;
      } else {
        console.error(`[TokenRefresh] 刷新失败: ${response.responseCode}`);
        return null;
      }
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[TokenRefresh] 刷新异常: ${err.message}`);
      return null;
    }
  }

  /**
   * 撤销令牌
   * 用户退出登录时应调用此方法
   */
  async revokeToken(): Promise<boolean> {
    if (!this.config.revocationEndpoint || !this.currentTokens) {
      console.warn('[TokenRefresh] 撤销端点未配置或没有令牌');
      await this.tokenStore.clearTokens();
      this.currentTokens = null;
      return true;
    }

    try {
      const requestBody = new Map<string, string>([
        ['token', this.currentTokens.access_token],
        ['token_type_hint', 'access_token'],
        ['client_id', this.config.clientId],
      ]);

      const bodyString = Array.from(requestBody.entries())
        .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
        .join('&');

      const httpRequest = http.createHttp();
      const response = await httpRequest.request(
        this.config.revocationEndpoint!,
        {
          method: http.RequestMethod.POST,
          header: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          extraData: bodyString,
        }
      );

      if (response.responseCode === 200) {
        console.info('[TokenRefresh] 令牌已撤销');
        await this.tokenStore.clearTokens();
        this.currentTokens = null;
        return true;
      }

      return false;
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[TokenRefresh] 撤销异常: ${err.message}`);
      return false;
    }
  }
}

// 使用示例:带自动刷新的API请求
async function demoApiCallWithAutoRefresh() {
  const config: OAuth2Config = {
    clientId: 'com.example.myapp',
    redirectUri: 'hwauth://com.example.myapp/callback',
    scopes: ['profile', 'email'],
    authorizationEndpoint: 'https://oauth.huawei.com/authorize',
    tokenEndpoint: 'https://oauth.huawei.com/token',
    revocationEndpoint: 'https://oauth.huawei.com/revoke',
  };

  const refreshManager = new TokenRefreshManager(config);

  // 获取有效的访问令牌(自动刷新)
  const accessToken = await refreshManager.getValidAccessToken();

  if (accessToken) {
    // 使用令牌请求受保护资源
    console.info('[Demo] 使用令牌请求API...');
  }
}

3.3 多端 OAuth 适配

HarmonyOS 的分布式特性意味着同一个应用可能在手机、平板、手表等多种设备上运行。不同设备的 OAuth 流程需要不同的适配策略。

import { deviceInfo } from '@kit.BasicServicesKit';
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';

/**
 * 多端OAuth适配器
 * 根据设备类型选择最合适的OAuth流程
 */
class MultiDeviceOAuthAdapter {
  private config: OAuth2Config;

  constructor(config: OAuth2Config) {
    this.config = config;
  }

  /**
   * 获取当前设备类型
   */
  getDeviceType(): 'phone' | 'tablet' | 'watch' | 'tv' | 'car' {
    const deviceType = deviceInfo.deviceType;
    switch (deviceType) {
      case 'phone':
        return 'phone';
      case 'tablet':
        return 'tablet';
      case 'wearable':
        return 'watch';
      case 'tv':
        return 'tv';
      case 'car':
        return 'car';
      default:
        return 'phone';
    }
  }

  /**
   * 根据设备类型选择OAuth流程
   */
  selectOAuthFlow(): OAuthFlowStrategy {
    const deviceType = this.getDeviceType();

    switch (deviceType) {
      case 'phone':
      case 'tablet':
        // 手机/平板:使用完整的授权码+PKCE流程
        return {
          flowType: 'authorization_code_pkce',
          redirectMethod: 'deep_link',
          tokenStorage: 'huks_encrypted',
          description: '标准授权码流程,PKCE增强安全',
        };

      case 'watch':
        // 手表:屏幕太小,使用设备授权流程
        return {
          flowType: 'device_code',
          redirectMethod: 'pairing_code',
          tokenStorage: 'huks_encrypted',
          description: '设备授权流程,在配对手机上完成授权',
        };

      case 'tv':
        // 电视:使用设备授权流程+二维码
        return {
          flowType: 'device_code',
          redirectMethod: 'qr_code',
          tokenStorage: 'huks_encrypted',
          description: '设备授权流程,手机扫码完成授权',
        };

      case 'car':
        // 车机:使用设备授权流程+配对码
        return {
          flowType: 'device_code',
          redirectMethod: 'pairing_code',
          tokenStorage: 'huks_encrypted',
          description: '设备授权流程,手机配对完成授权',
        };

      default:
        return {
          flowType: 'authorization_code_pkce',
          redirectMethod: 'deep_link',
          tokenStorage: 'huks_encrypted',
          description: '默认授权码流程',
        };
    }
  }

  /**
   * 设备授权流程(适用于手表、电视等无屏/小屏设备)
   * 1. 向服务器请求设备码和用户码
   * 2. 在配对设备上显示用户码或二维码
   * 3. 用户在手机/网页上输入用户码完成授权
   * 4. 设备轮询服务器获取令牌
   */
  async startDeviceCodeFlow(): Promise<DeviceCodeResponse | null> {
    try {
      // 请求设备码
      const requestBody = new Map<string, string>([
        ['client_id', this.config.clientId],
        ['scope', this.config.scopes.join(' ')],
      ]);

      const bodyString = Array.from(requestBody.entries())
        .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
        .join('&');

      const httpRequest = http.createHttp();
      const response = await httpRequest.request(
        this.config.deviceCodeEndpoint ?? 'https://oauth.huawei.com/device/code',
        {
          method: http.RequestMethod.POST,
          header: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          extraData: bodyString,
        }
      );

      if (response.responseCode === 200) {
        const deviceCodeData = JSON.parse(response.result as string) as DeviceCodeResponse;
        console.info('[MultiDevice] 设备码获取成功');
        console.info(`[MultiDevice] 用户码: ${deviceCodeData.user_code}`);
        console.info(`[MultiDevice] 验证URL: ${deviceCodeData.verification_uri}`);

        // 开始轮询
        this.pollForToken(deviceCodeData);

        return deviceCodeData;
      }

      return null;
    } catch (error) {
      const err = error as BusinessError;
      console.error(`[MultiDevice] 设备码流程失败: ${err.message}`);
      return null;
    }
  }

  /**
   * 轮询服务器等待用户完成授权
   */
  private async pollForToken(deviceCode: DeviceCodeResponse): Promise<void> {
    const maxAttempts = Math.floor(deviceCode.expires_in / deviceCode.interval);
    let attempt = 0;

    const poll = async (): Promise<void> => {
      if (attempt >= maxAttempts) {
        console.error('[MultiDevice] 设备码已过期');
        return;
      }

      attempt++;

      try {
        const requestBody = new Map<string, string>([
          ['grant_type', 'urn:ietf:params:oauth:grant-type:device_code'],
          ['device_code', deviceCode.device_code],
          ['client_id', this.config.clientId],
        ]);

        const bodyString = Array.from(requestBody.entries())
          .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
          .join('&');

        const httpRequest = http.createHttp();
        const response = await httpRequest.request(
          this.config.tokenEndpoint,
          {
            method: http.RequestMethod.POST,
            header: {
              'Content-Type': 'application/x-www-form-urlencoded',
            },
            extraData: bodyString,
          }
        );

        if (response.responseCode === 200) {
          const tokens = JSON.parse(response.result as string) as TokenResponse;
          console.info('[MultiDevice] 令牌获取成功(设备授权流程)');
          // 存储令牌...
          return;
        }

        const errorData = JSON.parse(response.result as string);
        if (errorData.error === 'authorization_pending') {
          // 用户尚未完成授权,继续轮询
          setTimeout(poll, deviceCode.interval * 1000);
        } else if (errorData.error === 'slow_down') {
          // 轮询太快,增加间隔
          setTimeout(poll, (deviceCode.interval + 5) * 1000);
        } else {
          console.error(`[MultiDevice] 轮询错误: ${errorData.error}`);
        }
      } catch (error) {
        const err = error as BusinessError;
        console.error(`[MultiDevice] 轮询异常: ${err.message}`);
        setTimeout(poll, deviceCode.interval * 1000);
      }
    };

    // 首次轮询延迟
    setTimeout(poll, deviceCode.interval * 1000);
  }
}

/**
 * OAuth流程策略
 */
interface OAuthFlowStrategy {
  flowType: string;            // 流程类型
  redirectMethod: string;      // 重定向方式
  tokenStorage: string;        // 令牌存储方式
  description: string;         // 策略描述
}

/**
 * 设备码响应
 */
interface DeviceCodeResponse {
  device_code: string;         // 设备码
  user_code: string;           // 用户码
  verification_uri: string;    // 验证URL
  verification_uri_complete?: string; // 完整验证URL(含用户码)
  expires_in: number;          // 过期时间(秒)
  interval: number;            // 轮询间隔(秒)
}

// 使用示例
async function demoMultiDeviceOAuth() {
  const config: OAuth2Config = {
    clientId: 'com.example.myapp',
    redirectUri: 'hwauth://com.example.myapp/callback',
    scopes: ['openid', 'profile'],
    authorizationEndpoint: 'https://oauth.huawei.com/authorize',
    tokenEndpoint: 'https://oauth.huawei.com/token',
  };

  const adapter = new MultiDeviceOAuthAdapter(config);

  // 检测设备类型并选择流程
  const strategy = adapter.selectOAuthFlow();
  console.info(`[Demo] 当前设备: ${adapter.getDeviceType()}`);
  console.info(`[Demo] OAuth策略: ${strategy.description}`);

  // 如果是手表/电视,使用设备授权流程
  if (strategy.flowType === 'device_code') {
    await adapter.startDeviceCodeFlow();
  }
}

四、踩坑与注意事项

4.1 授权码只能用一次

这是最常见的坑——授权码使用一次后就失效了,如果重试会报错:

// ❌ 错误:重复使用授权码
const tokens1 = await exchangeToken(code); // 第一次成功
const tokens2 = await exchangeToken(code); // 第二次报错!授权码已失效

// ✅ 正确:授权码只用一次,令牌通过刷新获取
const tokens = await exchangeToken(code);
// 后续使用 refresh_token 刷新

4.2 State 参数必须验证

State 参数是防止 CSRF 攻击的关键。如果不验证 state,攻击者可以伪造授权回调:

class StateValidator {
  private expectedState: string = '';

  /**
   * 生成并保存state
   */
  generateState(): string {
    const state = this.generateRandomString(32);
    this.expectedState = state;
    return state;
  }

  /**
   * 验证回调中的state
   * 必须与之前生成的一致
   */
  validateState(receivedState: string): boolean {
    if (!receivedState || receivedState !== this.expectedState) {
      console.error('[StateValidator] State验证失败,可能遭受CSRF攻击');
      return false;
    }
    console.info('[StateValidator] State验证通过');
    return true;
  }

  private generateRandomString(length: number): string {
    const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let result = '';
    for (let i = 0; i < length; i++) {
      result += charset.charAt(Math.floor(Math.random() * charset.length));
    }
    return result;
  }
}

4.3 令牌不要存储在日志中

// ❌ 危险:令牌出现在日志中
console.info(`[Debug] access_token: ${tokens.access_token}`);

// ✅ 安全:只记录令牌的存在和有效期
console.info(`[Debug] 令牌已获取,有效期: ${tokens.expires_in}`);
console.info(`[Debug] access_token前4位: ${tokens.access_token.substring(0, 4)}***`);

4.4 重定向 URI 必须精确匹配

授权服务器对 redirect_uri 的匹配非常严格,包括协议、域名、路径、查询参数都必须完全一致:

// ❌ 不匹配
// 注册: hwauth://com.example.myapp/callback
// 请求: hwauth://com.example.myapp/callback/  (多了斜杠)
// 请求: hwauth://com.example.myapp/Callback  (大小写不同)

// ✅ 精确匹配
// 注册和请求使用完全相同的URI

4.5 网络超时处理

OAuth 涉及多次网络请求,必须做好超时和重试:

async function safeHttpRequest(
  url: string,
  options: http.HttpRequestOptions,
  timeoutMs: number = 10000,
  maxRetries: number = 2
): Promise<http.HttpResponse | null> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const httpRequest = http.createHttp();
      const response = await httpRequest.request(url, {
        ...options,
        connectTimeout: timeoutMs,
        readTimeout: timeoutMs,
      });
      return response;
    } catch (error) {
      lastError = error as Error;
      console.warn(`[Network] 请求失败(第${attempt + 1}次): ${lastError.message}`);

      if (attempt < maxRetries) {
        // 指数退避
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  console.error(`[Network] 请求最终失败: ${lastError?.message}`);
  return null;
}

五、HarmonyOS 6 适配

5.1 API 变更

变更项 HarmonyOS 5 HarmonyOS 6
授权跳转 通过Ability跳转 新增 @ohos.account.oauth 原生OAuth模块
令牌存储 手动加密存储 新增系统级令牌保险箱
设备授权 需自行实现轮询 新增 DeviceCodeClient 原生支持
令牌撤销 手动调用API 新增 revokeAllTokens() 一键撤销
生物识别绑定 不支持 新增令牌与生物识别绑定

5.2 迁移指南

// HarmonyOS 5: 手动实现OAuth流程
const authUrl = buildAuthorizationUrl(state);
// 通过Ability跳转打开授权页面...

// HarmonyOS 6: 使用原生OAuth模块
import { oauth } from '@kit.BasicServicesKit';

const oauthClient = oauth.createOAuthClient({
  clientId: 'com.example.myapp',
  scopes: ['openid', 'profile', 'email'],
});

// 一行代码启动授权
const authResult = await oauthClient.authorize({
  redirectUri: 'hwauth://com.example.myapp/callback',
  pkceEnabled: true, // 自动处理PKCE
});

// 令牌自动安全存储,无需手动管理
const accessToken = await oauthClient.getAccessToken();

5.3 令牌与生物识别绑定

HarmonyOS 6 新增了令牌与生物识别的绑定能力,敏感操作需要生物识别验证后才能使用令牌:

// HarmonyOS 6: 令牌与生物识别绑定
const boundToken = await oauthClient.getAccessToken({
  biometricRequired: true,  // 使用令牌前需要生物识别验证
  authType: 'fingerprint',  // 指纹验证
});

六、总结

核心知识点回顾

OAuth 2.0 认证体系
├── 授权码流程
│   ├── 授权请求 ── 构建URL,包含client_id、scope、state
│   ├── 用户授权 ── 在授权服务器完成确认
│   ├── 令牌交换 ── 用授权码+PKCE换取令牌
│   └── 资源访问 ── 用access_token请求受保护资源
├── PKCE增强
│   ├── code_verifier ── 客户端生成的随机字符串
│   ├── code_challenge ── code_verifier的SHA-256哈希
│   └── 验证流程 ── 服务器验证哈希匹配,防止授权码截获
├── 令牌管理
│   ├── access_token ── 短期有效(1-2小时),用于资源访问
│   ├── refresh_token ── 长期有效(30-90天),用于刷新
│   ├── 自动刷新 ── 检测过期,自动使用refresh_token
│   └── 令牌撤销 ── 退出登录时主动撤销
├── 多端适配
│   ├── 手机/平板 ── 授权码+PKCE流程
│   ├── 手表 ── 设备授权流程(配对码)
│   ├── 电视 ── 设备授权流程(二维码)
│   └── 车机 ── 设备授权流程(配对码)
└── 安全实践
    ├── State验证 ── 防止CSRF攻击
    ├── 令牌安全存储 ── HUKS加密,不写日志
    ├── 授权码一次性 ── 用后即废,不可重试
    ├── URI精确匹配 ── redirect_uri必须完全一致
    └── 网络超时 ── 指数退避重试

OAuth 2.0 是现代应用认证的基石,而 PKCE 是移动端 OAuth 的安全护盾。理解了授权码流程、令牌刷新机制和多端适配策略,你就能在任何 HarmonyOS 设备上构建安全可靠的认证体验。记住:安全不是可选项,而是必须项——每一个 state 验证、每一次 PKCE 增强都是在为用户的数据安全加锁。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。