Inkstone · blog

不要把长期天气 token 打进 iOS 包:匿名 session 换短期凭证的设计

2,037 words 6 min read #Cloudflare#Security#iOS#Token

匿名天气 token 生命周期

心橱是本地优先 App。用户不注册、不登录,也应该能看到天气:今天适合穿什么、明天是否降温、旅行计划里天气如何。

天气数据来自第三方 provider,比如彩云天气和 Visual Crossing。第三方 provider key 肯定不能打进 iOS 包,所以天气请求走后端代理。这一步没问题。

真正的问题是:客户端怎么证明自己有资格调用“自家后端天气代理”?

最简单的做法是在 iOS 包里放一个 CLOSET_WEATHER_PROXY_TOKEN,请求天气时带:

X-Weather-Token: <token>

这个 token 不是第三方天气 provider key,权限很低。但它只要进了 iOS 包,就必须假设会被逆向拿到。一旦泄露,所有客户端共用同一个长期 token,后端只能整体吊销;旧版本 App 的天气也会一起失效。

所以这次改成了匿名 session 换短期 weather token。

设计目标

这个方案不追求“绝对防滥用”。客户端是公开分发的软件,任何客户端凭证都有泄露可能。

目标更现实:

  1. 不要求用户注册或登录。
  2. 不把长期共享 token 打进 iOS 包。
  3. 泄露一个 token 时,只影响一个匿名 session。
  4. 后端可以按 session 观察、限流、吊销。
  5. 旧版本静态 token 可以短期兼容,便于灰度。

换句话说:把风险从“一个全局钥匙”降级成“很多张短期门票”。

数据流

第一次需要天气时,iOS 创建匿名天气 session:

POST /v1/weather/session

请求体:

{
"platform": "ios",
"appVersion": "1.0",
"installationId": "random-uuid"
}

这里的 installationId 是 App 自己生成的随机 UUID,保存在 UserDefaults。它不是 IDFA,不读取硬件唯一标识,也不用于广告追踪。

后端返回:

{
"anonymousSessionId": "aws_xxx",
"weatherAccessToken": "wta_xxx",
"refreshToken": "wtr_xxx",
"expiresAt": "2026-06-03T20:00:00Z",
"refreshAfter": "2026-06-03T14:00:00Z"
}

之后天气请求改带:

X-Weather-Session-Token: wta_xxx

如果 access token 到期,或者天气接口返回 401,iOS 调刷新接口:

POST /v1/weather/session/refresh
{
"anonymousSessionId": "aws_xxx",
"refreshToken": "wtr_xxx"
}

刷新成功后,后端发一组新的 access token 和 refresh token,旧 refresh token 立刻作废。

TTL 怎么定

这版选择:

  • weatherAccessToken:12 小时
  • refreshAfter:6 小时
  • refreshToken:30 天

12 小时 access token 的好处是用户当天使用基本不会频繁刷新;即使 token 被拿到,滥用窗口也有限。

6 小时 refreshAfter 是一个“主动轮换建议”。iOS 如果发现已经过了 refreshAfter,会尝试刷新;如果刷新失败但 access token 还没过期,就继续用旧 access token,避免因为一次网络抖动让天气立刻不可用。

30 天 refresh token 是为了照顾本地优先 App 的体验。用户可能很多天不打开 App,如果 refresh token 太短,天气会话重建会更频繁。这里的风险通过“refresh token 只在刷新接口使用、刷新后旧 token 作废、服务端只存 hash”来控制。

后端表结构

D1 和本地数据库都有同构表。

匿名 session 表:

CREATE TABLE IF NOT EXISTS anonymous_weather_sessions (
id TEXT PRIMARY KEY,
installation_id_hash TEXT NOT NULL,
platform TEXT NOT NULL,
app_version TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
revoked_at TEXT
);

token 表:

CREATE TABLE IF NOT EXISTS weather_session_tokens (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES anonymous_weather_sessions(id),
token_hash TEXT NOT NULL UNIQUE,
token_type TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_at TEXT
);

注意两个细节:

  1. installationId 入库前会 hash。
  2. access token 和 refresh token 入库前也会 hash。

后端只在创建/刷新响应里返回明文 token,不在数据库里保存明文。校验时把客户端传来的 token hash 后查表。

后端鉴权逻辑

天气接口的鉴权入口是 require_weather_token

它现在优先检查 session token:

if weather_session_token and await store.weather_session_for_access_token(weather_session_token):
return "weather_session"

然后才检查旧的静态 token:

if weather_token and expected_token and compare_digest(weather_token, expected_token):
return "weather_token"

这样做是为了灰度:

  • 新版 iOS 使用 X-Weather-Session-Token
  • 旧版 iOS 仍然可以短期用 X-Weather-Token
  • 后端响应里会带 authMode,方便观察到底是哪种路径在调用。

等新版覆盖稳定后,就可以逐步降低旧 token 的额度,最后移除。

iOS 端怎么保存和刷新

iOS 新增了 WeatherSessionService

它做几件事:

  1. 通过 WeatherSessionStore 从 Keychain 读取已保存 session。
  2. 如果没有 session,调用 /v1/weather/session 创建。
  3. 如果 access token 还没到 refreshAfter,直接使用。
  4. 如果过了 refreshAfter,先尝试 refresh;失败但 access token 未过期时继续使用旧 token。
  5. 如果 access token 已过期,强制 refresh。
  6. 如果 refresh 返回 401,清空本地 session 并重新创建。

天气请求层也很简单:

let accessToken = try await weatherSessionProvider.accessToken()
try await client.get(
path: path,
queryItems: queryItems,
headers: ["X-Weather-Session-Token": accessToken],
as: BackendWeatherResponse.self
)

如果天气接口返回 401,就主动刷新并重试一次:

catch let error as BackendAPIError where error.statusCode == 401 {
let refreshedAccessToken = try await weatherSessionProvider.refreshAccessToken()
return try await weatherGet(path: path, queryItems: queryItems, accessToken: refreshedAccessToken)
}

这能覆盖“后端吊销了 access token”或“本地 token 已过期但客户端还没来得及刷新”的情况。

为什么不直接要求登录

因为天气不是账号能力,它是本地穿搭体验的一部分。

如果为了天气强制用户注册,会把一个轻量功能变成账号门槛:用户还没决定要不要长期使用 App,就先被要求交出邮箱。这和本地优先 App 的体验目标冲突。

匿名 session 是折中方案:

  • 对用户来说,不是注册。
  • 对后端来说,不再是所有客户端共享同一把钥匙。
  • 对风控来说,有了 session 粒度,可以观察和限制。

这个方案防什么,不防什么

它能防的是:

  • 静态 token 泄露导致全局失控。
  • 无法区分不同客户端来源。
  • 不能单独吊销某个异常客户端。
  • 旧版本升级期间没有灰度空间。

它不能防的是:

  • 有人批量自动创建匿名 session。
  • 有人抓包拿到某个 session 的短期 token。
  • 有人模拟正常 App 调天气接口。

所以后续还可以继续加:

  • 按 session 限流。
  • 按 IP 限流。
  • 按经纬度网格限流,避免同一 session 扫全球天气。
  • 异常 session 吊销。
  • App Attest / DeviceCheck,提高自动化滥用成本。

第一版先把“全局长期 token”换成“匿名 session + 可轮换短期 token”,已经把风险边界缩小了很多。

上线顺序

我建议这类改造按这个顺序上线:

  1. 后端保留旧 X-Weather-Token,新增匿名 session endpoints。
  2. iOS 新版优先用匿名 session token。
  3. 后端通过 authMode 观察新旧调用比例。
  4. 新版稳定后,对旧 token 单独限流。
  5. 最终移除 iOS 包内静态 weather token。

这比“一次性切掉旧 token”稳。移动端总有旧版本,后端需要给迁移留窗口。

测试重点

后端测试重点:

  • 创建 session 返回 anonymousSessionId、access token、refresh token、过期时间。
  • 数据库只保存 token hash,不保存明文。
  • access token 可以调用天气接口。
  • refresh 成功后旧 refresh token 不能再用。
  • D1 Worker 路径和 FastAPI 路径都覆盖。

iOS 测试重点:

  • 首次天气请求会先创建 session。
  • 天气请求发送的是 X-Weather-Session-Token,不是旧 X-Weather-Token
  • 401 后会 refresh 并重试一次。
  • session 存在 Keychain,installation id 是随机 UUID。

隐私说明

这个方案会创建匿名设备会话,但不应该表述为“注册账号”。

隐私政策里应该说明:

  • App 可能创建匿名安装会话,用于天气服务、额度控制、安全和滥用防护。
  • installation id 是随机生成的,不是设备硬件 ID。
  • 不用于广告追踪。
  • 不与用户真实身份绑定,除非用户之后主动登录或注册。

这是移动端安全设计经常遇到的取舍:不可能完全相信客户端,但可以让每一次信任都更短、更窄、更可撤销。

参考