欢迎光临
我们一直在努力

HarmonyOS 安全存储实战:Token 生命周期、刷新互斥、退出清理和日志脱敏

HarmonyOS 安全存储实战:Token 生命周期、刷新互斥、退出清理和日志脱敏

登录态问题通常不是登录接口本身,而是 Token 生命周期没有设计好:access token 过期后多个请求同时刷新,refresh token 被覆盖,退出登录只清了内存没清安全存储,日志里打印了敏感字段,用户切账号后仍拿到上一个账号的数据。

这篇文章只解决一个工程问题:HarmonyOS 应用如何把 Token 安全存储、过期判断、刷新互斥、退出清理和日志脱敏做成一条可靠链路。

请添加图片描述

本文会落到四个结果:

  • Token 不散落在页面和普通缓存里。
  • 过期前自动刷新,但同一时间只允许一个刷新请求。
  • 退出登录时清理内存、安全存储、请求队列和用户态。
  • 日志和错误信息不泄露 Token、手机号、用户 id 等敏感数据。
  • 一、先区分四类数据:不是所有登录信息都能同样存

    登录相关数据要分级。

    数据风险建议
    access token 可直接访问接口 安全存储,短生命周期
    refresh token 可换取新 token 更严格保护,退出必清
    userId / tenantId 可关联用户身份 日志脱敏,必要时存储
    昵称、头像 低敏展示数据 可普通缓存,但要随账号清理

    最常见的问题是把所有登录信息都塞进一个 UserInfo,然后随手写到 Preferences 或日志里。安全存储的第一步是分级。

    二、资料与版本边界:本文写应用层凭据治理

    本文示例面向 HarmonyOS NEXT / ArkTS 工程,应用层重点放在 Token 模型、持久化边界、刷新互斥、退出清理和日志脱敏。具体加密存储能力可结合 Keychain / Universal Keystore Kit / Preferences 等官方能力与项目安全要求选择。

    请添加图片描述

    层级本文关注不展开
    存储层 Token 读写、清理、过期 加密算法内部实现
    请求层 过期判断、自动刷新、重试 后端鉴权实现
    会话层 切账号、退出登录、状态广播 复杂 SSO 体系
    日志层 脱敏、错误记录、排查 安全审计平台

    请添加图片描述

    三、Token 模型:明确过期时间和来源

    Token 不能只保存字符串。

    export interface TokenBundle {
    accessToken: string;
    refreshToken: string;
    accessExpireAt: number;
    refreshExpireAt: number;
    userId: string;
    issuedAt: number;
    }

    export function accessTokenExpired(bundle: TokenBundle, safeWindowMs: number): boolean {
    return Date.now() + safeWindowMs >= bundle.accessExpireAt;
    }

    export function refreshTokenExpired(bundle: TokenBundle): boolean {
    return Date.now() >= bundle.refreshExpireAt;
    }

    这里的 safeWindowMs 很重要。不要等 Token 刚好过期才刷新,可以提前一两分钟刷新,减少临界点失败。

    四、安全存储接口:页面不能直接碰 Token

    把 Token 读写封装成仓储,不让页面直接访问存储能力。

    export interface TokenStore {
    save(bundle: TokenBundle): Promise<void>;
    load(): Promise<TokenBundle | undefined>;
    clear(): Promise<void>;
    }

    export class SecureTokenRepository {
    constructor(private readonly store: TokenStore) {}

    async saveToken(bundle: TokenBundle): Promise<void> {
    await this.store.save(bundle);
    }

    async getToken(): Promise<TokenBundle | undefined> {
    return this.store.load();
    }

    async clearToken(): Promise<void> {
    await this.store.clear();
    }
    }

    这层的价值:

  • 页面不知道 Token 存在哪里。
  • 后续替换 Keychain、Preferences 或加密方案时,不影响业务层。
  • 清理逻辑有统一入口。
  • 单元测试可以替换 TokenStore。
  • 五、刷新互斥:多个请求不要同时刷新

    并发刷新是登录态最常见的坑。多个接口同时发现 access token 过期,如果都去刷新,可能导致 refresh token 被服务端轮换后互相覆盖。

    export interface RefreshResult {
    success: boolean;
    bundle?: TokenBundle;
    message: string;
    }

    export class TokenRefreshGate {
    private refreshing?: Promise<RefreshResult>;

    async run(refreshAction: () => Promise<RefreshResult>): Promise<RefreshResult> {
    if (this.refreshing !== undefined) {
    return this.refreshing;
    }

    this.refreshing = refreshAction();
    try {
    return await this.refreshing;
    } finally {
    this.refreshing = undefined;
    }
    }
    }

    这段代码保证同一时间只有一个刷新动作。其他请求等待结果,避免把 refresh token 用乱。

    六、请求前拦截:先判断,再带 Token

    请求层要在发送前拿到可用 Token。

    export class AuthHeaderProvider {
    constructor(
    private readonly repository: SecureTokenRepository,
    private readonly refreshGate: TokenRefreshGate
    ) {}

    async buildAuthHeader(refreshAction: () => Promise<RefreshResult>): Promise<Record<string, string>> {
    const bundle = await this.repository.getToken();
    if (bundle === undefined) {
    return {};
    }

    if (refreshTokenExpired(bundle)) {
    await this.repository.clearToken();
    return {};
    }

    if (accessTokenExpired(bundle, 120000)) {
    const refreshed = await this.refreshGate.run(refreshAction);
    if (!refreshed.success || refreshed.bundle === undefined) {
    return {};
    }
    await this.repository.saveToken(refreshed.bundle);
    return { Authorization: `Bearer ${refreshed.bundle.accessToken}` };
    }

    return { Authorization: `Bearer ${bundle.accessToken}` };
    }
    }

    边界说明:

  • refresh token 过期后直接清理登录态,不做无意义重试。
  • access token 快过期时走互斥刷新。
  • 刷新成功后先保存,再返回新 header。
  • 返回空 header 时,上层应进入重新登录或游客态。
  • 七、退出登录:清理不止一个 Token

    退出登录要清理内存、持久化、请求队列、页面状态和用户缓存。

    export interface LogoutCleaner {
    clearMemory(): Promise<void>;
    clearSecureStore(): Promise<void>;
    cancelPendingRequests(): Promise<void>;
    clearUserCache(): Promise<void>;
    resetNavigation(): Promise<void>;
    }

    export async function logoutCompletely(cleaner: LogoutCleaner): Promise<void> {
    await cleaner.cancelPendingRequests();
    await cleaner.clearMemory();
    await cleaner.clearSecureStore();
    await cleaner.clearUserCache();
    await cleaner.resetNavigation();
    }

    顺序也有意义:先取消未完成请求,再清 Token;否则某个请求可能在退出后继续拿旧 Token 写数据。

    八、切账号:比退出登录更容易漏数据

    切账号要防止 A 账号缓存污染 B 账号。

    export interface AccountSession {
    userId: string;
    token: TokenBundle;
    createdAt: number;
    }

    export function sameAccount(current: AccountSession | undefined, nextUserId: string): boolean {
    return current !== undefined && current.userId === nextUserId;
    }

    export async function switchAccount(
    current: AccountSession | undefined,
    next: AccountSession,
    cleaner: LogoutCleaner,
    repository: SecureTokenRepository
    ): Promise<void> {
    if (!sameAccount(current, next.userId)) {
    await logoutCompletely(cleaner);
    }
    await repository.saveToken(next.token);
    }

    切账号时要清页面栈、用户缓存、草稿和请求队列。否则最容易出现“头像变了,但列表还是上一个账号的数据”。

    九、日志脱敏:排查不能牺牲安全

    日志里不要打印完整 Token。

    export function maskToken(token: string): string {
    if (token.length <= 12) {
    return '***';
    }
    return `${token.slice(0, 6)}***${token.slice(4)}`;
    }

    export interface AuthLog {
    action: 'login' | 'refresh' | 'logout' | 'expired' | 'switchAccount';
    userIdMasked: string;
    tokenMasked?: string;
    success: boolean;
    message: string;
    timestamp: number;
    }

    日志需要能排查,但不能泄露。userId、手机号、邮箱、Token、设备标识都要按安全要求处理。

    十、Token 问题排查表

    现象优先怀疑检查方式修复方向
    多个请求同时 401 并发刷新 查 refresh 日志 加 TokenRefreshGate
    退出后仍访问接口 未取消请求 查 pending request 退出先取消请求
    切账号数据串了 用户缓存未清 查 cache key key 带 userId 或退出清理
    日志有敏感信息 未脱敏 查 auth 日志 使用 maskToken()
    refresh token 失效仍重试 过期判断缺失 查过期时间 过期后清登录态
    重新打开仍是旧账号 安全存储未清 查 TokenStore clear() 必须可靠

    登录态问题排查不要只看接口返回码,要看 Token 生命周期和请求并发。

    十一、上线前安全验收表

    检查项通过标准
    Token 分级清楚 access/refresh/userInfo 不混存
    存储入口统一 页面不直接读写 Token
    刷新互斥 同一时间只有一个刷新请求
    退出清理完整 内存、存储、请求、缓存、页面都清
    切账号不串数据 缓存和请求按用户隔离
    日志已脱敏 不打印完整 Token 和敏感身份
    过期有兜底 refresh 过期进入重新登录

    这张表适合放到登录态改造和发版前自查里。

    十二、安全存储相关官方资料

  • 华为开发者文档:Universal Keystore Kit
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/universal-keystore-kit-overview
  • 华为开发者文档:Keychain Kit
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/keychain-kit-introduction
  • 华为开发者文档:Preferences
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-preferences
  • 华为开发者文档:应用权限与安全
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/security-overview
  • 十三、把登录态做成可恢复也可清理的系统

    安全存储不是单点能力,而是登录态治理的一部分。Token 模型、存储仓储、刷新互斥、退出清理、切账号隔离和日志脱敏缺一不可。

    最后用这张表复盘:

    问题稳定答案
    Token 存在哪里 统一 SecureTokenRepository
    什么时候刷新 access token 快过期时提前刷新
    多个请求谁刷新 TokenRefreshGate 互斥
    退出清哪些东西 请求、内存、存储、缓存、页面
    日志怎么排查 只记录脱敏字段和动作结果
    赞(0)
    未经允许不得转载:171主机测评 » HarmonyOS 安全存储实战:Token 生命周期、刷新互斥、退出清理和日志脱敏
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址