最近在折腾项目的时候碰到了这个知识点,查了不少资料,索性整理出来分享给大家。
第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 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)
暂无评论