聊聊32-Token明文存储-把凭据边界收到安全仓储里(整理分享)

最近在折腾项目的时候碰到了这个知识点,查了不少资料,索性整理出来分享给大家。

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

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

一次联调里,测试同学为了排查接口 401 导出了 hilog。咱们本来只想看请求耗时,结果在日志里见到了一整段 Authorization: Bearer ...。继续查下去,普通 Preferences 里有 session_tokenAppStorage 里有 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 tokenSessionStore会话摘要、加载状态、过期提示完整凭据字符串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
  load(): Promise
  clear(): Promise
}

export function isTokenExpired(pair: TokenPair, skewMs: number = 60_000): boolean {
  return pair.expiresAt  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 {
    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(SESSION_KEY, createSignedOutSession())
}

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

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

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

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

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

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

  async build(): Promise {
    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
}

export class TokenRefreshService {
  private refreshing: Promise | null = null

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

  async refreshIfNeeded(): Promise {
    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 {
    const next = await this.authRemote.refresh(refreshToken)
    await this.tokenVault.save(next)
  }
}

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

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

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

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

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

  async migrate(): Promise {
    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 {
    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): Record {
const safe: Record = {}

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


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论