鸿蒙Next版ArkTS实现JWT鉴权与token管理

2026-09-04 14:09:41 24 次阅读

鸿蒙Next应用中的登录鉴权通常由客户端与服务端共同完成。用户输入账号密码后,服务端校验身份并签发JWT,ArkTS客户端负责保存token、携带token访问接口、处理token过期以及退出登录。真正稳定的实现并不只是“登录后把token存起来”,还需要考虑请求封装、自动刷新、并发刷新、token失效和安全存储等问题。

JWT鉴权的基本工作流程

JWT(JSON Web Token)是一种常见的无状态身份认证方案。典型流程如下:

用户输入账号密码
        ↓
ArkTS调用登录接口
        ↓
服务端校验用户身份
        ↓
服务端生成 Access Token
        ↓
客户端安全保存 Token
        ↓
后续HTTP请求携带 Authorization
        ↓
服务端验证JWT
        ↓
返回业务数据

请求头一般采用Bearer认证方式:

http
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

JWT通常由Header、Payload和Signature三部分组成:

Header.Payload.Signature

需要特别注意,JWT的Payload默认只是Base64URL编码,并不是加密数据。因此不要把密码、身份证号、银行卡号等敏感信息直接放进JWT。

ArkTS项目中的Token管理应该解决什么问题

一个完整的token管理模块至少需要处理以下几个场景:

  1. 登录成功后保存token。

  2. 普通接口自动添加Authorization请求头。

  3. 应用启动时恢复登录状态。

  4. Access Token过期后自动刷新。

  5. Refresh Token失效后清除登录状态。

  6. 多个请求同时遇到401时避免重复刷新。

  7. 用户主动退出登录时清理认证信息。

  8. 避免把敏感token直接写入普通业务配置。

  9. 网络请求异常时区分认证失败与普通网络错误。

如果把这些逻辑散落在每个页面里,项目规模扩大后很容易出现重复代码和状态不一致的问题。

更合理的结构可以划分为:

UI页面
  ↓
业务Service
  ↓
HttpClient
  ↓
TokenManager
  ↓
TokenStorage

页面只负责登录、退出以及展示用户状态,HTTP层负责统一请求,TokenManager负责token生命周期管理。

定义Token数据模型

ArkTS可以先定义一个简单的数据结构:

TypeScript
export interface TokenInfo {
  accessToken: string
  refreshToken?: string
  expiresAt?: number
}

如果服务端直接返回过期秒数,也可以保存:

TypeScript
export interface LoginResponse {
  accessToken: string
  refreshToken: string
  expiresIn: number
}

拿到登录结果后,将expiresIn转换为客户端时间戳:

TypeScript
const expiresAt = Date.now() + response.expiresIn * 1000

实际项目中建议提前一小段时间判断token是否过期,例如提前60秒刷新,而不是等服务端已经返回401之后才开始处理。

使用ArkTS封装TokenManager

TokenManager可以作为整个应用的认证状态中心:

TypeScript
export class TokenManager {
  private static instance: TokenManager

  private accessToken: string = ''
  private refreshToken: string = ''
  private expiresAt: number = 0

  private constructor() {}

  public static getInstance(): TokenManager {
    if (!TokenManager.instance) {
      TokenManager.instance = new TokenManager()
    }
    return TokenManager.instance
  }

  public setToken(
    accessToken: string,
    refreshToken: string,
    expiresAt: number
  ): void {
    this.accessToken = accessToken
    this.refreshToken = refreshToken
    this.expiresAt = expiresAt
  }

  public getAccessToken(): string {
    return this.accessToken
  }

  public getRefreshToken(): string {
    return this.refreshToken
  }

  public isExpired(): boolean {
    return !this.accessToken || Date.now() >= this.expiresAt
  }

  public clear(): void {
    this.accessToken = ''
    this.refreshToken = ''
    this.expiresAt = 0
  }
}

这里使用单例主要是为了保证整个应用访问的是同一份认证状态。

不过,单例本身不能解决应用进程被系统回收的问题。因此真正的ArkTS应用还需要搭配持久化存储,让应用重新启动后能够恢复必要的认证状态。

Token持久化保存

鸿蒙应用可以根据数据的敏感程度选择合适的安全存储方案。

对于JWT token这类认证凭证,不建议简单使用普通Preferences文件作为唯一安全保障。更合理的做法是根据HarmonyOS版本、项目安全要求以及官方API能力选择安全存储机制,例如系统提供的安全存储相关能力。

业务代码可以抽象成:

TypeScript
export interface TokenStorage {
  save(token: TokenInfo): Promise<void>
  load(): Promise<TokenInfo | null>
  clear(): Promise<void>
}

这样TokenManager不需要关心底层到底采用哪一种存储方式。

例如:

TypeScript
class SecureTokenStorage implements TokenStorage {

  async save(token: TokenInfo): Promise<void> {
    // 使用项目选定的安全存储API
  }

  async load(): Promise<TokenInfo | null> {
    // 从安全存储中读取Token
    return null
  }

  async clear(): Promise<void> {
    // 删除Token
  }
}

这种设计还有一个好处:后续需要更换存储实现时,不需要修改页面和HTTP请求代码。

登录接口的ArkTS实现

假设服务端提供:

POST /api/auth/login

请求参数:

JSON
{
  "username": "admin",
  "password": "123456"
}

服务端返回:

JSON
{
  "accessToken": "xxx",
  "refreshToken": "xxx",
  "expiresIn": 7200
}

ArkTS业务层可以封装:

TypeScript
interface LoginRequest {
  username: string
  password: string
}

async function login(params: LoginRequest): Promise<void> {
  const response = await httpClient.post<LoginResponse>(
    '/api/auth/login',
    params
  )

  const expiresAt = Date.now() + response.expiresIn * 1000

  const tokenManager = TokenManager.getInstance()

  tokenManager.setToken(
    response.accessToken,
    response.refreshToken,
    expiresAt
  )
}

实际项目中不要让UI页面直接操作底层token存储。

例如不建议在登录页面里同时完成:

HTTP请求
Token解析
Token保存
SharedPreferences操作
刷新逻辑
用户状态更新

页面代码最终会变得非常臃肿。

HTTP请求统一添加JWT

JWT鉴权最适合放到统一HTTP客户端中。

伪代码结构可以设计为:

TypeScript
class HttpClient {

  async request<T>(
    method: string,
    url: string,
    body?: Object
  ): Promise<T> {

    const tokenManager = TokenManager.getInstance()
    const token = tokenManager.getAccessToken()

    const headers: Record<string, string> = {
      'Content-Type': 'application/json'
    }

    if (token) {
      headers['Authorization'] = `Bearer ${token}`
    }

    // 调用HarmonyOS HTTP相关API
    // 发送请求并处理响应

    return {} as T
  }

  async get<T>(url: string): Promise<T> {
    return this.request<T>('GET', url)
  }

  async post<T>(url: string, body: Object): Promise<T> {
    return this.request<T>('POST', url, body)
  }
}

这样业务代码只需要:

TypeScript
const user = await httpClient.get<UserInfo>('/api/user/info')

而不需要每一次都手动写:

TypeScript
headers['Authorization'] = `Bearer ${token}`

这也是ArkTS项目中比较重要的工程化思路:认证逻辑应该集中管理,而不是分散到业务页面。

Token过期后的刷新机制

实际项目中通常会使用Access Token和Refresh Token。

两者职责不同:

Access Token
短生命周期
频繁携带
用于访问业务API

Refresh Token
生命周期更长
用于获取新的Access Token
不应该频繁发送给普通业务接口

例如:

Access Token:2小时
Refresh Token:14天

当Access Token过期时:

业务请求
   ↓
服务器返回401
   ↓
客户端使用Refresh Token
   ↓
调用刷新接口
   ↓
获得新的Access Token
   ↓
重新执行原请求

刷新接口可以类似:

POST /api/auth/refresh

请求:

JSON
{
  "refreshToken": "xxxx"
}

响应:

JSON
{
  "accessToken": "new-access-token",
  "refreshToken": "new-refresh-token",
  "expiresIn": 7200
}

是否返回新的Refresh Token取决于后端的token轮换策略。

避免多个请求同时刷新Token

这是JWT客户端实现中非常容易被忽略的问题。

假设Access Token同时过期,页面又发出了5个接口请求:

Request A → 401
Request B → 401
Request C → 401
Request D → 401
Request E → 401

如果每个请求都单独调用刷新接口,就可能瞬间产生5次refresh请求。

正确方式通常是设置一个正在刷新的Promise:

TypeScript
class TokenRefresher {
  private refreshPromise: Promise<string> | null = null

  async refresh(): Promise<string> {
    if (this.refreshPromise) {
      return this.refreshPromise
    }

    this.refreshPromise = this.doRefresh()

    try {
      return await this.refreshPromise
    } finally {
      this.refreshPromise = null
    }
  }

  private async doRefresh(): Promise<string> {
    // 调用刷新Token接口
    // 保存新的Token
    return ''
  }
}

这样第一个请求负责真正刷新token,其他请求直接等待同一个Promise。

最终流程变成:

请求A ─┐
请求B ─┤
请求C ─┼→ 等待同一次Token刷新
请求D ─┤
请求E ─┘
          ↓
      Refresh Token
          ↓
       新Token
          ↓
      重新发送请求

这种设计能够明显降低服务端刷新接口的压力,也能避免多个刷新请求互相覆盖token。

处理401与刷新失败

客户端不能把所有401都简单理解成“Token过期”。

401可能意味着:

  • Access Token过期;

  • Access Token签名无效;

  • Refresh Token失效;

  • 用户被服务端注销;

  • Token被服务端加入黑名单;

  • 登录状态已经失效。

因此比较合理的处理方式是:

普通请求
   ↓
返回401
   ↓
判断是否已经重试
   ↓
尝试Refresh Token
   ↓
刷新成功 → 重试原请求
   ↓
刷新失败 → 清除认证状态
             ↓
          返回登录页

尤其需要设置“只重试一次”。

否则如果服务端持续返回401,而客户端不断自动重试,就可能形成请求循环。

例如:

TypeScript
interface RequestOptions {
  retry?: boolean
}

第一次请求:

TypeScript
{
  retry: false
}

刷新成功后重新请求:

TypeScript
{
  retry: true
}

如果重试后的请求依然返回401,就不再刷新。

自动刷新Token的完整结构

可以把请求流程抽象成:

TypeScript
async function requestWithAuth<T>(
  request: () => Promise<T>
): Promise<T> {

  try {
    return await request()
  } catch (error) {

    if (!isUnauthorized(error)) {
      throw error
    }

    const newToken = await tokenRefresher.refresh()

    if (!newToken) {
      TokenManager.getInstance().clear()
      throw error
    }

    return await request()
  }
}

不过真实项目中最好把401处理放在统一HTTP层,而不是每个业务方法都调用一次。

最终业务层可以保持非常简单:

TypeScript
async function getUserInfo(): Promise<UserInfo> {
  return httpClient.get<UserInfo>('/api/user/info')
}

认证、刷新、重试等细节全部由HttpClient内部处理。

应用启动时恢复登录状态

用户关闭鸿蒙应用后重新打开,内存中的TokenManager已经不存在,因此启动阶段需要恢复认证信息。

可以设计初始化方法:

TypeScript
class AuthManager {

  async initialize(): Promise<boolean> {
    const token = await this.storage.load()

    if (!token) {
      return false
    }

    TokenManager.getInstance().setToken(
      token.accessToken,
      token.refreshToken ?? '',
      token.expiresAt ?? 0
    )

    return !TokenManager.getInstance().isExpired()
  }
}

页面根据初始化结果决定进入首页还是登录页:

App启动
   ↓
读取Token
   ↓
Token不存在 ─────→ 登录页
   ↓
Token存在
   ↓
Token有效 ───────→ 首页
   ↓
Token过期
   ↓
尝试Refresh Token
   ↓
刷新成功 ────────→ 首页
   ↓
刷新失败 ────────→ 登录页

为了避免启动过程中出现“先显示登录页,随后突然跳到首页”的闪烁,可以在应用初始化完成之前显示一个启动状态页面。

退出登录的正确处理

退出登录不应该只做页面跳转。

简单的:

TypeScript
router.pushUrl({
  url: 'pages/Login'
})

并不能真正完成退出。

至少需要:

TypeScript
async function logout(): Promise<void> {
  try {
    await httpClient.post('/api/auth/logout', {})
  } finally {
    await tokenStorage.clear()
    TokenManager.getInstance().clear()
  }
}

为什么使用finally

因为即使服务端退出接口失败,客户端也应该清除本地认证状态,否则用户可能看到登录页面,但旧token依然存在。

如果服务端采用无状态JWT,并没有保存Access Token黑名单,那么服务端的logout接口是否真正让Access Token立即失效,需要结合具体JWT设计判断。

JWT客户端安全注意事项

Token管理最重要的不是代码能否运行,而是凭证泄露后会造成什么后果。

不要打印完整Token

调试时不要:

TypeScript
console.info(`token=${accessToken}`)

因为日志可能被开发工具、测试环境日志系统或其他组件获取。

如果必须调试,可以只打印部分信息:

TypeScript
console.info(
  `token=${accessToken.substring(0, 8)}...`
)

生产环境则建议彻底关闭敏感日志。

不要把Token写入URL

不要使用:

https://example.com/api/user?token=xxxx

URL可能出现在访问日志、代理日志、浏览器历史或其他系统中。

优先使用:

http
Authorization: Bearer xxxx

不要把敏感信息放入JWT Payload

JWT Payload可以被客户端解码,因此:

JSON
{
  "userId": 10001,
  "username": "admin"
}

通常问题不大,但:

JSON
{
  "password": "123456",
  "bankCard": "..."
}

则属于明显错误。

JWT中的数据应该遵循“即使被解码,也不会造成敏感信息泄露”的原则。

全程使用HTTPS

JWT本质上是访问凭证。如果HTTP链路被窃听,攻击者拿到有效Access Token后就可能冒用用户身份。

因此生产环境应使用HTTPS,并对证书校验、网络安全配置进行合理加固。

Access Token和Refresh Token应该如何选择

小型项目经常只使用一个JWT:

登录 → JWT
所有请求 → JWT
过期 → 重新登录

实现简单,但用户体验较差。

更成熟的方案是:

Access Token
+
Refresh Token

Access Token有效期短,可以降低泄露后的风险;Refresh Token负责维持较长时间的登录状态。

不过Refresh Token本身也是高价值凭证,因此不能因为它“不用于普通API请求”就忽略安全保护。

如果后端支持Refresh Token轮换,可以采用:

旧Refresh Token
      ↓
换取新Access Token
      +
新Refresh Token
      ↓
旧Refresh Token失效

这种机制能够进一步降低Refresh Token长期复用带来的风险。

ArkTS项目推荐的目录结构

随着项目规模扩大,可以将认证相关代码独立出来:

entry/
├── src/
│   └── main/
│       ├── ets/
│       │   ├── common/
│       │   │   ├── HttpClient.ets
│       │   │   └── HttpError.ets
│       │   │
│       │   ├── auth/
│       │   │   ├── AuthManager.ets
│       │   │   ├── TokenManager.ets
│       │   │   ├── TokenStorage.ets
│       │   │   └── TokenRefresher.ets
│       │   │
│       │   ├── service/
│       │   │   ├── UserService.ets
│       │   │   └── LoginService.ets
│       │   │
│       │   └── pages/
│       │       ├── LoginPage.ets
│       │       └── HomePage.ets

这样的分层可以让JWT鉴权逻辑与UI页面解耦。

常见错误及排查思路

请求始终返回401

先检查请求头:

http
Authorization: Bearer 

重点确认是否误写成:

http
Authorization: 

或者:

http
Token: 

同时检查服务端JWT签名算法、密钥、Issuer、Audience以及过期时间配置是否一致。

登录成功但重启应用后需要重新登录

重点检查Token是否真正持久化,以及应用启动阶段是否执行了Token恢复。

不要只依赖:

TypeScript
private accessToken: string = ''

因为这属于内存数据,进程结束后自然会消失。

多次出现刷新Token请求

检查是否存在多个请求同时执行刷新逻辑。

建议使用共享Promise:

TypeScript
private refreshPromise: Promise<string> | null = null

保证同一时间只存在一个刷新任务。

刷新成功后原请求仍然401

检查重新请求时是否使用了最新Access Token。

一种常见错误是:

TypeScript
const token = oldToken

await refreshToken()

request.headers.Authorization = `Bearer ${token}`

这里仍然使用旧token。

更合理的做法是重新从TokenManager读取:

TypeScript
const newToken =
  TokenManager.getInstance().getAccessToken()

request.headers.Authorization =
  `Bearer ${newToken}`

退出登录后仍然能够访问接口

检查是否只清除了UI状态,而没有清理持久化Token。

需要同时处理:

内存Token
+
持久化Token
+
认证状态

如果服务端还维护Session、Refresh Token记录或Token黑名单,也需要同步执行服务端注销。

一个更完整的认证架构

成熟的鸿蒙Next版ArkTS JWT鉴权可以形成这样的闭环:

                    ┌──────────────┐
                    │   登录页面    │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │ AuthManager  │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │ TokenManager │
                    └──────┬───────┘
                           │
                ┌──────────┴──────────┐
                ▼                     ▼
        Secure Token Storage    HttpClient
                                      │
                                      ▼
                                业务API请求
                                      │
                            ┌─────────┴─────────┐
                            │                   │
                           2xx                 401
                            │                   │
                            ▼                   ▼
                         返回数据          Refresh Token
                                                │
                                      ┌─────────┴─────────┐
                                      │                   │
                                    成功                  失败
                                      │                   │
                                      ▼                   ▼
                                  重试请求             清除Token
                                                          │
                                                          ▼
                                                        登录页

这种架构的核心并不是某一个ArkTS API,而是把Token存储、请求鉴权、Token刷新、登录状态和退出登录形成完整的生命周期管理。

总结

鸿蒙Next版ArkTS实现JWT鉴权时,建议采用“UI与认证逻辑分离、HTTP统一注入Token、Access Token与Refresh Token配合、安全存储认证凭证”的设计方式。

最基础的实现可以概括为:

登录
 ↓
获取JWT
 ↓
安全保存Token
 ↓
HTTP请求自动携带Authorization
 ↓
Access Token过期
 ↓
Refresh Token刷新
 ↓
更新Token并重试请求
 ↓
刷新失败
 ↓
清理认证状态并重新登录

对于正式项目,还应重点处理并发刷新、请求只重试一次、应用启动恢复、安全日志、HTTPS、Token生命周期以及服务端注销策略。只有把这些环节一起考虑,ArkTS中的JWT鉴权才能从“能够登录”真正提升到“稳定、安全、可维护”的token管理方案。