第32篇|Token 明文泄露排查:用 Asset Store Kit 把登录凭据锁回安全边界
摘要:登录 Token 最危险的地方,不是"它被存了一次",而是它会顺着页面状态、请求日志、异常上报和旧版本迁移扩散出去。实际项目里要先把泄露链路画出来,再把凭据收进唯一的 TokenVault:页面只拿会话摘要,请求前短时读取,刷新和退出登录都走同一个边界,发布前用搜索和重启验证证明明文没有残留。
一次联调里,测试同学为了排查接口 401 导出了 hilog。我们本来只想看请求耗时,结果在日志里看到了一整段 Authorization: Bearer ...。继续查下去,普通 Preferences 里有 session_token,AppStorage 里有 accessToken,崩溃上报的扩展字段里还带了登录响应体。这个问题不能靠"把日志删掉"结束,因为真正的问题是:Token 没有 owner,谁想用都能拿,谁出错都可能带出去。

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


先还原一次真实泄露链路
安全问题最怕只说原则。排查时我会先按"Token 从哪里来、被谁复制、最后从哪里出去"画链路。
| 链路位置 | 当时看到的现象 | 风险点 | 修复方向 |
|---|---|---|---|
| 登录回调 | LoginPage 直接拿到 accessToken |
页面截图、断点、路由参数都可能接触凭据 | 登录服务只返回会话摘要 |
| 普通存储 | Preferences 里有 session_token |
误备份、误导出、旧版本迁移残留 | 迁到安全仓储适配器 |
| 全局状态 | AppStorage 里保存 accessToken |
跨页面扩散,任意页面都能读 | 只保存 signedIn、用户昵称等摘要 |
| 请求日志 | 打印完整 Authorization |
hilog、远程日志和截图泄露 | 日志工具统一脱敏 |
| 异常上报 | 上报登录响应体 | Token 离开设备进入平台 | 异常上下文白名单化 |
这张表的作用不是写文档,而是决定代码边界。凡是"为了方便"复制 Token 的地方,都要从业务代码里拆出去。
先定边界:Token 只能有一个 owner
改造前,很多模块都觉得自己"只是临时用一下 Token"。最后的结果是页面、请求层、缓存层、日志层都有副本。
更稳的边界是:
| 模块 | 可以知道什么 | 不应该知道什么 |
|---|---|---|
| Page / Component | 是否登录、头像、昵称、用户 ID | access token、refresh token |
| SessionStore | 会话摘要、加载状态、过期提示 | 完整凭据字符串 |
| LoginService | 登录结果、保存凭据动作 | 安全存储实现细节 |
| ApiClient | 临时 Authorization header | 长期缓存的 Token 字段 |
| Logger / Reporter | traceId、接口名、错误码 | Bearer、Cookie、登录响应体 |
| TokenVault | Token 的读、写、删 | 页面展示逻辑 |
只要这个表定下来,后面的代码就不会摇摆:页面不碰 Token,请求不保存 Token,日志不透传 Token,只有 TokenVault 管真实凭据。
用 TokenVault 表达唯一读写入口
先不要急着在页面里调用安全存储 API。先抽一个足够小的接口,让业务层只知道"保存、读取、删除"。
ts
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(),而是集中在一个适配器里。
ts
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。
ts
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。这会让所有页面都有读取凭据的机会。
ts
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 后就不再保存。
ts
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。
ts
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,升级时会遇到迁移问题。迁移时的底线是:读取旧值、写入新仓储、删除旧值,全程不打印。
ts
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')
}
}
如果迁移失败,不要为了"用户无感"继续保留明文。安全类改造宁可让用户重新登录,也不要把旧风险带到新版本。
日志和异常上报必须做白名单
脱敏不是在每个调用点靠自觉,而是日志工具统一处理。尤其是请求失败、登录失败和异常上报,这几个地方最容易把完整上下文带出去。
ts
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 带出去。
退出登录要清四个位置
退出登录不能只把页面状态改成未登录。真正要清的是:安全仓储、页面会话摘要、请求队列和旧明文键。
ts
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 的请求,说明还有别的缓存路径没有清干净。
发布前用搜索证明没有明文路径
人工看代码很容易漏。发布前直接用搜索把风险字段扫一遍。
powershell
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 不会在页面、运行态、普通存储和调试链路里到处旅行,排查和交付也有明确证据。