鸿蒙Next应用中的登录鉴权通常由客户端与服务端共同完成。用户输入账号密码后,服务端校验身份并签发JWT,ArkTS客户端负责保存token、携带token访问接口、处理token过期以及退出登录。真正稳定的实现并不只是“登录后把token存起来”,还需要考虑请求封装、自动刷新、并发刷新、token失效和安全存储等问题。
JWT鉴权的基本工作流程
JWT(JSON Web Token)是一种常见的无状态身份认证方案。典型流程如下:
用户输入账号密码 ↓ ArkTS调用登录接口 ↓ 服务端校验用户身份 ↓ 服务端生成 Access Token ↓ 客户端安全保存 Token ↓ 后续HTTP请求携带 Authorization ↓ 服务端验证JWT ↓ 返回业务数据
请求头一般采用Bearer认证方式:
httpAuthorization: Bearer eyJhbGciOiJIUzI1NiIs...
JWT通常由Header、Payload和Signature三部分组成:
Header.Payload.Signature
需要特别注意,JWT的Payload默认只是Base64URL编码,并不是加密数据。因此不要把密码、身份证号、银行卡号等敏感信息直接放进JWT。
ArkTS项目中的Token管理应该解决什么问题
一个完整的token管理模块至少需要处理以下几个场景:
-
登录成功后保存token。
-
普通接口自动添加Authorization请求头。
-
应用启动时恢复登录状态。
-
Access Token过期后自动刷新。
-
Refresh Token失效后清除登录状态。
-
多个请求同时遇到401时避免重复刷新。
-
用户主动退出登录时清理认证信息。
-
避免把敏感token直接写入普通业务配置。
-
网络请求异常时区分认证失败与普通网络错误。
如果把这些逻辑散落在每个页面里,项目规模扩大后很容易出现重复代码和状态不一致的问题。
更合理的结构可以划分为:
UI页面 ↓ 业务Service ↓ HttpClient ↓ TokenManager ↓ TokenStorage
页面只负责登录、退出以及展示用户状态,HTTP层负责统一请求,TokenManager负责token生命周期管理。
定义Token数据模型
ArkTS可以先定义一个简单的数据结构:
TypeScriptexport interface TokenInfo { accessToken: string refreshToken?: string expiresAt?: number }
如果服务端直接返回过期秒数,也可以保存:
TypeScriptexport interface LoginResponse { accessToken: string refreshToken: string expiresIn: number }
拿到登录结果后,将expiresIn转换为客户端时间戳:
TypeScriptconst expiresAt = Date.now() + response.expiresIn * 1000
实际项目中建议提前一小段时间判断token是否过期,例如提前60秒刷新,而不是等服务端已经返回401之后才开始处理。
使用ArkTS封装TokenManager
TokenManager可以作为整个应用的认证状态中心:
TypeScriptexport 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能力选择安全存储机制,例如系统提供的安全存储相关能力。
业务代码可以抽象成:
TypeScriptexport interface TokenStorage { save(token: TokenInfo): Promise<void> load(): Promise<TokenInfo | null> clear(): Promise<void> }
这样TokenManager不需要关心底层到底采用哪一种存储方式。
例如:
TypeScriptclass 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业务层可以封装:
TypeScriptinterface 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客户端中。
伪代码结构可以设计为:
TypeScriptclass 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) } }
这样业务代码只需要:
TypeScriptconst user = await httpClient.get<UserInfo>('/api/user/info')
而不需要每一次都手动写:
TypeScriptheaders['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:
TypeScriptclass 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,而客户端不断自动重试,就可能形成请求循环。
例如:
TypeScriptinterface RequestOptions { retry?: boolean }
第一次请求:
TypeScript{ retry: false }
刷新成功后重新请求:
TypeScript{ retry: true }
如果重试后的请求依然返回401,就不再刷新。
自动刷新Token的完整结构
可以把请求流程抽象成:
TypeScriptasync 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层,而不是每个业务方法都调用一次。
最终业务层可以保持非常简单:
TypeScriptasync function getUserInfo(): Promise<UserInfo> { return httpClient.get<UserInfo>('/api/user/info') }
认证、刷新、重试等细节全部由HttpClient内部处理。
应用启动时恢复登录状态
用户关闭鸿蒙应用后重新打开,内存中的TokenManager已经不存在,因此启动阶段需要恢复认证信息。
可以设计初始化方法:
TypeScriptclass 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 ↓ 刷新成功 ────────→ 首页 ↓ 刷新失败 ────────→ 登录页
为了避免启动过程中出现“先显示登录页,随后突然跳到首页”的闪烁,可以在应用初始化完成之前显示一个启动状态页面。
退出登录的正确处理
退出登录不应该只做页面跳转。
简单的:
TypeScriptrouter.pushUrl({ url: 'pages/Login' })
并不能真正完成退出。
至少需要:
TypeScriptasync 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
调试时不要:
TypeScriptconsole.info(`token=${accessToken}`)
因为日志可能被开发工具、测试环境日志系统或其他组件获取。
如果必须调试,可以只打印部分信息:
TypeScriptconsole.info( `token=${accessToken.substring(0, 8)}...` )
生产环境则建议彻底关闭敏感日志。
不要把Token写入URL
不要使用:
https://example.com/api/user?token=xxxx
URL可能出现在访问日志、代理日志、浏览器历史或其他系统中。
优先使用:
httpAuthorization: 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
先检查请求头:
httpAuthorization: Bearer
重点确认是否误写成:
httpAuthorization:
或者:
httpToken:
同时检查服务端JWT签名算法、密钥、Issuer、Audience以及过期时间配置是否一致。
登录成功但重启应用后需要重新登录
重点检查Token是否真正持久化,以及应用启动阶段是否执行了Token恢复。
不要只依赖:
TypeScriptprivate accessToken: string = ''
因为这属于内存数据,进程结束后自然会消失。
多次出现刷新Token请求
检查是否存在多个请求同时执行刷新逻辑。
建议使用共享Promise:
TypeScriptprivate refreshPromise: Promise<string> | null = null
保证同一时间只存在一个刷新任务。
刷新成功后原请求仍然401
检查重新请求时是否使用了最新Access Token。
一种常见错误是:
TypeScriptconst token = oldToken await refreshToken() request.headers.Authorization = `Bearer ${token}`
这里仍然使用旧token。
更合理的做法是重新从TokenManager读取:
TypeScriptconst 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管理方案。