AD0227头像

32-Token明文存储-把凭据边界收到安全仓储里

第32篇|Token 明文泄露排查:用 Asset Store Kit 把登录凭据锁回安全边界

摘要:登录 Token 最危险的地方,不是“它被存了一次”,而是它会顺着页面状态、请求日志、异常上报和旧版本迁移扩散出去。实际项目里要先把泄露链路画出来,再把凭据收进唯一的 TokenVault:页面只拿会话摘要,请求前短时读取,刷新和退出登录都走同一个边界,发布前用搜索和重启验证证明明文没有残留。

一次联调里,测试同学为了排查接口 401 导出了 hilog。我们本来只想看请求耗时,结果在日志里看到了一整段 Authorization: Bearer ...。继续查下去,普通 Preferences 里有 session_token,AppStorage 里有 accessToken,崩溃上报的扩展字段里还带了登录响应体。这个问题不能靠“把日志删掉”结束,因为真正的问题是:Token 没有 owner,谁想用都能拿,谁出错都可能带出去。

在这里插入图片描述

这篇文章把修复拆成四件事:

  1. 从日志、运行态、普通存储和异常上报还原 Token 泄露链路。
  2. 用 TokenVault 收口凭据读写,底层再接 Asset Store Kit 等安全存储能力。
  3. 把页面会话状态和真实凭据拆开,避免 AppStorage 变成 Token 中转站。
  4. 覆盖登录、刷新、迁移、退出和发布前扫描,防止只修一条路径。

在这里插入图片描述
在这里插入图片描述

先还原一次真实泄露链路

安全问题最怕只说原则。排查时我会先按“Token 从哪里来、被谁复制、最后从哪里出去”画链路。

链路位置当时看到的现象风险点修复方向
登录回调LoginPage 直接拿到 accessToken页面截图、断点、路由参数都可能接触凭据登录服务只返回会话摘要
普通存储Preferences 里有 session_token误备份、误导出、旧版本迁移残留迁到安全仓储适配器
全局状态AppStorage 里保存 accessToken跨页面扩散,任意页面都能读只保存 signedIn、用户昵称等摘要
请求日志打印完整 Authorizationhilog、远程日志和截图泄露日志工具统一脱敏
异常上报上报登录响应体Token 离开设备进入平台异常上下文白名单化

这张表的作用不是写文档,而是决定代码边界。凡是“为了方便”复制 Token 的地方,都要从业务代码里拆出去。

先定边界:Token 只能有一个 owner

改造前,很多模块都觉得自己“只是临时用一下 Token”。最后的结果是页面、请求层、缓存层、日志层都有副本。

更稳的边界是:

模块可以知道什么不应该知道什么
Page / Component是否登录、头像、昵称、用户 IDaccess token、refresh token
SessionStore会话摘要、加载状态、过期提示完整凭据字符串
LoginService登录结果、保存凭据动作安全存储实现细节
ApiClient临时 Authorization header长期缓存的 Token 字段
Logger / ReportertraceId、接口名、错误码Bearer、Cookie、登录响应体
TokenVaultToken 的读、写、删页面展示逻辑

只要这个表定下来,后面的代码就不会摇摆:页面不碰 Token,请求不保存 Token,日志不透传 Token,只有 TokenVault 管真实凭据。

用 TokenVault 表达唯一读写入口

先不要急着在页面里调用安全存储 API。先抽一个足够小的接口,让业务层只知道“保存、读取、删除”。

export interface TokenPair {
  accessToken: string
  refreshToken: string
  expiresAt: number
}

export interface TokenVault {
  save(pair: TokenPair): Promise<void>
  load(): Promise<TokenPair | null>
  clear(): Promise<void>
}

export function isTokenExpired(pair: TokenPair, skewMs: number = 60_000): boolean {
  return pair.expiresAt <= Date.now() + skewMs
}

这段接口只负责凭据生命周期,不负责页面跳转,也不负责弹登录框。这样做有两个好处:一是测试环境可以替换成内存实现,二是后面从 Preferences 迁到 Asset Store Kit 时,不需要改页面。

Asset Store Kit 适配器只藏在基础设施层

HarmonyOS 的 Asset Store Kit 面向短敏感数据的安全存储。项目里不要让各业务模块直接散落 asset.add()、asset.query()、asset.remove(),而是集中在一个适配器里。

import { asset } from '@kit.AssetStoreKit'
import { util } from '@kit.ArkTS'

const TOKEN_ALIAS = 'auth.token.pair'

function toBytes(value: string): Uint8Array {
  const encoder = new util.TextEncoder()
  return encoder.encodeInto(value)
}

function fromBytes(value: Uint8Array): string {
  const decoder = new util.TextDecoder()
  return decoder.decodeWithStream(value)
}

export class AssetStoreTokenVault implements TokenVault {
  async save(pair: TokenPair): Promise<void> {
    await this.clear()

    const attributes: asset.AssetMap = new Map()
    attributes.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS))
    attributes.set(asset.Tag.SECRET, toBytes(JSON.stringify(pair)))
    attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_FIRST_UNLOCKED)
    attributes.set(asset.Tag.DATA_LABEL_NORMAL_1, toBytes('login-token'))

    await asset.add(attributes)
  }

  async load(): Promise<TokenPair | null> {
    const query: asset.AssetMap = new Map()
    query.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS))
    query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL)

    const result = await asset.query(query)
    const secret = result[0]?.get(asset.Tag.SECRET) as Uint8Array | undefined
    if (!secret) {
      return null
    }

    return JSON.parse(fromBytes(secret)) as TokenPair
  }

  async clear(): Promise<void> {
    const query: asset.AssetMap = new Map()
    query.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS))
    await asset.remove(query).catch(() => undefined)
  }
}

这里有三个关键点。第一,ALIAS 要稳定,方便覆盖、查询和删除同一份资产。第二,SECRET 才放真实 Token,不要把 Token 塞到普通附属信息里。第三,读写异常不要向日志里拼接 Token 内容,最多记录错误码和别名。

登录服务保存凭据,但只把摘要交给页面

登录成功后,页面真正需要的是“用户已经登录,可以展示昵称和头像”。页面不需要 Token。

export interface LoginResult {
  userId: string
  displayName: string
  avatarUrl: string
  accessToken: string
  refreshToken: string
  expiresAt: number
}

export interface SessionSnapshot {
  signedIn: boolean
  userId: string
  displayName: string
  avatarUrl: string
}

export function createSignedOutSession(): SessionSnapshot {
  return { signedIn: false, userId: '', displayName: '', avatarUrl: '' }
}

export class LoginService {
  constructor(private readonly tokenVault: TokenVault) {}

  async finishLogin(result: LoginResult): Promise<SessionSnapshot> {
    await this.tokenVault.save({
      accessToken: result.accessToken,
      refreshToken: result.refreshToken,
      expiresAt: result.expiresAt
    })

    return {
      signedIn: true,
      userId: result.userId,
      displayName: result.displayName,
      avatarUrl: result.avatarUrl
    }
  }
}

这段代码把“凭据保存”和“页面展示”拆开了。以后我的页、设置页、首页只消费 SessionSnapshot,没有理由读取 TokenPair。

AppStorage 只能保存会话摘要

很多明文泄露不是从磁盘开始,而是从运行态开始。为了让多个页面都能判断登录状态,有人会把 accessToken 放进 AppStorage。这会让所有页面都有读取凭据的机会。

const SESSION_KEY = 'session_snapshot'

export function hydrateSessionSnapshot(): void {
  AppStorage.setOrCreate<SessionSnapshot>(SESSION_KEY, createSignedOutSession())
}

export function publishSignedInSession(snapshot: SessionSnapshot): void {
  AppStorage.setOrCreate<SessionSnapshot>(SESSION_KEY, snapshot)
}

export function publishSignedOutSession(): void {
  AppStorage.setOrCreate<SessionSnapshot>(SESSION_KEY, createSignedOutSession())
}

如果页面用 @StorageLink 读取会话状态,要确保应用启动时先水合默认值。这样页面首次渲染拿到的是稳定的摘要对象,不会因为状态未初始化而在 Builder 里访问到 undefined。

请求层短时读取,用完就丢

请求层需要 Token,但不应该把 Token 变成长期字段。更好的方式是在发送前短时读取,组完 header 后就不再保存。

export class AuthorizationHeaderProvider {
  constructor(
    private readonly tokenVault: TokenVault,
    private readonly tokenRefreshService: TokenRefreshService
  ) {}

  async build(): Promise<Record<string, string>> {
    await this.tokenRefreshService.refreshIfNeeded()

    const pair = await this.tokenVault.load()
    if (pair === null || isTokenExpired(pair, 0)) {
      return {}
    }

    return { Authorization: `Bearer ${pair.accessToken}` }
  }
}

这里不要把 pair 缓存在单例属性里,也不要把 header 透传给日志工具。请求完成后,业务代码只应该得到响应结果、状态码和 traceId。

刷新 Token 也要走同一条路

很多项目登录时收口了,刷新时又绕回普通存储。刷新逻辑必须和登录逻辑一样,仍然写回 TokenVault。

export interface AuthRemote {
  refresh(refreshToken: string): Promise<TokenPair>
}

export class TokenRefreshService {
  private refreshing: Promise<void> | null = null

  constructor(
    private readonly tokenVault: TokenVault,
    private readonly authRemote: AuthRemote
  ) {}

  async refreshIfNeeded(): Promise<void> {
    const current = await this.tokenVault.load()
    if (current === null || !isTokenExpired(current)) {
      return
    }

    if (this.refreshing === null) {
      this.refreshing = this.refreshOnce(current.refreshToken)
        .finally(() => {
          this.refreshing = null
        })
    }

    await this.refreshing
  }

  private async refreshOnce(refreshToken: string): Promise<void> {
    const next = await this.authRemote.refresh(refreshToken)
    await this.tokenVault.save(next)
  }
}

refreshing 的作用是防止多个接口同时发现过期后一起刷新,导致后写入的旧结果覆盖新结果。更重要的是,新 Token 仍然只进入 TokenVault,不会回到页面状态。

旧版本明文迁移要宁可重新登录

如果线上版本已经把 Token 写进普通 Preferences,升级时会遇到迁移问题。迁移时的底线是:读取旧值、写入新仓储、删除旧值,全程不打印。

export interface LegacyCredentialStore {
  getString(key: string): Promise<string>
  delete(key: string): Promise<void>
}

export class LegacyTokenMigrator {
  constructor(
    private readonly legacyStore: LegacyCredentialStore,
    private readonly tokenVault: TokenVault
  ) {}

  async migrate(): Promise<void> {
    const accessToken = await this.legacyStore.getString('session_token')
    const refreshToken = await this.legacyStore.getString('refresh_token')

    if (accessToken.length === 0 || refreshToken.length === 0) {
      await this.dropLegacyKeys()
      return
    }

    await this.tokenVault.save({
      accessToken,
      refreshToken,
      expiresAt: Date.now() + 10 * 60 * 1000
    })
    await this.dropLegacyKeys()
  }

  private async dropLegacyKeys(): Promise<void> {
    await this.legacyStore.delete('session_token')
    await this.legacyStore.delete('refresh_token')
  }
}

如果迁移失败,不要为了“用户无感”继续保留明文。安全类改造宁可让用户重新登录,也不要把旧风险带到新版本。

日志和异常上报必须做白名单

脱敏不是在每个调用点靠自觉,而是日志工具统一处理。尤其是请求失败、登录失败和异常上报,这几个地方最容易把完整上下文带出去。

const SECRET_FIELD_PATTERN = /(authorization|accessToken|refreshToken|session_token|cookie)/i

export function sanitizeLogFields(fields: Record<string, string>): Record<string, string> {
  const safe: Record<string, string> = {}

  Object.entries(fields).forEach(([key, value]) => {
    safe[key] = SECRET_FIELD_PATTERN.test(key) ? maskCredential(value) : value
  })

  return safe
}

export function maskCredential(value: string): string {
  if (value.length <= 8) {
    return '[masked]'
  }
  return `${value.slice(0, 4)}****${value.slice(value.length - 4)}`
}

异常上报建议采用白名单:接口名、错误码、traceId、页面名可以上报;header、cookie、登录响应体默认不能上报。这样即使新同学接入日志,也不容易把 Token 带出去。

退出登录要清四个位置

退出登录不能只把页面状态改成未登录。真正要清的是:安全仓储、页面会话摘要、请求队列和旧明文键。

export class LogoutService {
  constructor(
    private readonly tokenVault: TokenVault,
    private readonly apiQueue: ApiQueue,
    private readonly legacyStore: LegacyCredentialStore
  ) {}

  async logout(): Promise<void> {
    this.apiQueue.cancelUnauthorizedRequests()
    await this.tokenVault.clear()
    await this.legacyStore.delete('session_token')
    await this.legacyStore.delete('refresh_token')
    publishSignedOutSession()
  }
}

这一步要做重启验证:退出登录后杀掉应用再打开,如果仍然能发出带 Authorization 的请求,说明还有别的缓存路径没有清干净。

发布前用搜索证明没有明文路径

人工看代码很容易漏。发布前直接用搜索把风险字段扫一遍。

rg -n "accessToken|refreshToken|Authorization|Bearer|session_token|refresh_token" entry features common
rg -n "AppStorage.*token|Preferences.*token|hilog.*token|console.*token|logger.*Authorization" entry features common
rg -n "crash|report|track|event" entry features common

命中后按下面的规则处理:

命中类型能不能保留处理方式
DTO 字段声明可以确认只在登录结果和远端模型中出现
TokenVault 内部读写可以确认底层是安全仓储适配器
AppStorage / 页面字段不应该改成 SessionSnapshot
日志、埋点、异常上报不应该改成脱敏或白名单
旧 Preferences key仅迁移期保留迁移后删除,后续版本移除

搜索结果要作为交付证据保存下来。安全问题不能只说“应该没有”,要能指出哪些命中是合法边界,哪些已经删除。

验证清单

验证项操作预期结果
登录后普通存储搜索旧 key 和 Token 字段Preferences 不再出现完整 Token
页面运行态检查 AppStorage 和页面状态只有 SessionSnapshot,没有凭据
请求发送抓请求构造前后日志能发 Authorization,但日志不打印完整值
Token 刷新调短过期时间并并发请求只刷新一次,新 Token 写回 TokenVault
退出登录退出后杀进程重启不恢复旧账号,不再发带 Token 请求
异常上报人为制造 401 和崩溃上报内容只有错误码、traceId、页面名

如果只能做一次回归,优先测“登录 -> 请求 -> 刷新 -> 退出 -> 重启”。这条链路能覆盖大部分 Token 残留问题。

小结:不要让 Token 在项目里旅行

Token 明文存储的修复不是换一个存储 API 就结束。真正要改的是所有权:真实凭据只能在 TokenVault 里出现,页面拿会话摘要,请求前短时读取,刷新和迁移不能绕路,日志和异常上报只走白名单。这样处理后,Token 不会在页面、运行态、普通存储和调试链路里到处旅行,排查和交付也有明确证据。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/AD0227/article/details/162942679

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--