From 51856d5e7bef9640e0da29786d73533cab6cc8db Mon Sep 17 00:00:00 2001 From: guantw Date: Sat, 29 Aug 2026 17:11:42 +0800 Subject: [PATCH] fix: reuse token cache until server rejection Remove the creation-date gate from token cache loading so cached credentials can be reused across days. Refresh the access token only after an authenticated API request reports that it is invalid, while keeping signing-material cache expiry unchanged. Update CLI wording, user and agent documentation, security guidance, changelog entries, and regression tests for the new cache behavior. Validation: python -m ruff format --check .; python -m ruff check .; python -m pytest --cov (266 passed, 2 skipped, 78% coverage). --- CHANGELOG.md | 2 ++ README.md | 15 ++++++++------- SECURITY.md | 2 +- docs/AGENT_SIGNING.md | 7 ++++--- hapsign/cli.py | 10 ++++------ hapsign/pipeline.py | 24 +++++++++++------------- tests/test_pipeline.py | 18 +++++++++++++----- 7 files changed, 43 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a82c475..c9087dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,8 @@ ### Changed +- Token 缓存不再按创建日期主动失效;签名或 Profile 申请实际被服务端以 Token 失效 + 拒绝时才尝试刷新,跨天申请签名材料也会先复用现有 Token。 - CLI 现在必须显式使用 `doctor`、`inspect`、`migrate-cache`、`auth`、`devices`、 `sign`、`install` 或 `deploy` 子命令。`deploy`/`install` 必须传入非空 `--serial`;`sign` 可使用 `--serial`、可信的显式 `--device-udid`,或复用兼容的 diff --git a/README.md b/README.md index f851c74..d122cea 100644 --- a/README.md +++ b/README.md @@ -219,11 +219,12 @@ hapsign deploy --hap path/to/app-unsigned.hap --serial --json `connected=true` 的目标,优先选择 `physical_candidate=true` 的 USB 真机,再把其 `serial` 原样传给后续命令。`serial` 是 HDC 连接标识,不是签名 Profile 中的 UDID。 -`auth` 可以单独调用并持久化当天 Token。同一份 Token 缓存不绑定目标设备,在同一 +`auth` 可以单独调用并持久化 Token。同一份 Token 缓存不绑定目标设备,在同一 台运行 HapSign 的电脑上可继续给不同 HarmonyOS 手机、平板或 PC 目标签名;每台 目标设备的 Profile 仍绑定自己的 UDID,切换设备会重新申请 Profile。Token 不会在 -多台运行 HapSign 的电脑之间自动同步,也不建议手工复制缓存。`auth status` 只检查 -本地当日缓存,因此 JSON 中 `online_verified` 固定为 `false`。 +多台运行 HapSign 的电脑之间自动同步,也不建议手工复制缓存。Token 缓存不会按日期 +主动失效;只有携带 Token 的 API 请求被服务端拒绝时才会尝试刷新。`auth status` 只 +检查本地缓存,因此 JSON 中 `online_verified` 固定为 `false`。 ### Agent / 半自动仅签名 @@ -411,13 +412,13 @@ signed_haps/ ## 缓存策略 -同一天内不会重复登录;签名文件只在 bundle 和目标 UDID 都相同时复用: +Token 缓存持续复用;签名文件只在当天且 bundle 和目标 UDID 都相同时复用: -- **Token 缓存**:`/.token_cache.json`,当天可跨目标设备复用 +- **Token 缓存**:`/.token_cache.json`,不按日期失效,可跨目标设备复用 - **签名文件缓存**:`/{bundle_name}/metadata.json`,当天仅为匹配的 bundle 和设备 UDID 复用 -- 跨天自动失效,重新走完整流程 -- Token 失效时自动刷新,刷新失败才回退到重新登录 +- 跨天仅签名文件缓存失效,申请新材料时仍先复用已有 Token +- 携带 Token 的 API 请求被服务端判定失效时自动刷新,刷新失败才回退到重新登录 这些文件包含敏感信息。Windows token 缓存使用当前用户 DPAPI 保护;Linux/macOS token 缓存是权限限制为 `0o600` 的明文文件,签名私钥等材料仍需按敏感文件保护。 diff --git a/SECURITY.md b/SECURITY.md index 82608ac..d82fbb0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ vulnerability reporting;启用后请使用仓库 Security 页的“Report a vu ## 本地敏感数据 -桌面、便携版和 CLI 默认会按应用配置在程序目录的 `signing_files/` 中保存当日 token +桌面、便携版和 CLI 默认会按应用配置在程序目录的 `signing_files/` 中保存 token 缓存、调试证书、Profile 和 `.p12` 密钥库;CLI 可用 `--state-dir` 或 `HAPSIGN_SIGNING_DIR` 覆盖。此默认值不依赖启动命令时的工作目录。这些文件已被 `.gitignore` 排除,但仍是本机敏感 diff --git a/docs/AGENT_SIGNING.md b/docs/AGENT_SIGNING.md index 4d94ebc..7bc6c0c 100644 --- a/docs/AGENT_SIGNING.md +++ b/docs/AGENT_SIGNING.md @@ -62,9 +62,10 @@ hapsign sign \ --json ``` -普通日志只写入标准错误,最终标准输出仍是单个 JSON 文档。没有当日登录/签名材料 -缓存时,命令会打开浏览器;agent 应提示用户在该浏览器中完成授权,然后继续等待 -进程退出。成功结果的 `signed_hap` 是可交付产物的绝对路径。 +普通日志只写入标准错误,最终标准输出仍是单个 JSON 文档。没有可用 Token 缓存时, +命令会打开浏览器;已有 Token 不按日期主动失效,仅在携带 Token 的 API 请求被服务端 +拒绝时尝试刷新。agent 应提示用户在浏览器中完成必要的授权,然后继续等待进程退出。 +成功结果的 `signed_hap` 是可交付产物的绝对路径。 Agent 默认应显式使用 `system_controlled`:它调用系统 Edge/Chrome,但使用隔离的临时 上下文,不复用用户 cookie、已保存密码或旧 SSO 状态,并可预授予登录页访问 loopback diff --git a/hapsign/cli.py b/hapsign/cli.py index 12648c8..1452f99 100644 --- a/hapsign/cli.py +++ b/hapsign/cli.py @@ -204,7 +204,7 @@ def _add_signing_options( parser.add_argument( "--refresh-token", action="store_true", - help="删除当日 Token 缓存并重新浏览器认证,同时刷新签名材料", + help="删除 Token 缓存并重新浏览器认证,同时刷新签名材料", ) parser.add_argument( "--refresh-signing", @@ -293,7 +293,7 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=_formatter, epilog="""\ 示例: - hapsign auth --json # 有当日缓存则复用,否则打开浏览器 + hapsign auth --json # 有缓存则复用,否则打开浏览器 hapsign auth --refresh --json # 强制重新浏览器认证 hapsign auth status --json # 只检查本地缓存,不验证服务端有效性 """, @@ -353,7 +353,7 @@ def build_parser() -> argparse.ArgumentParser: description=( "为 --serial/--device-udid 对应设备生成 debug Profile 并签名;" "未指定设备时可复用兼容缓存或从当前 HDC 目标读取 UDID。" - "已有当日 Auth Token 会复用;" + "已有 Auth Token 会复用;" "同一 bundle 切换设备时会自动丢弃不匹配的 Profile 缓存。" ), formatter_class=_formatter, @@ -695,9 +695,7 @@ def _run_auth(args: argparse.Namespace) -> int: "ok": True, "command": "auth status", "message": ( - "发现当日认证缓存" - if status["authenticated"] - else "没有可用的当日认证缓存" + "发现认证缓存" if status["authenticated"] else "没有可用的认证缓存" ), "cache_format": _cache_format(str(status["cache_path"])), } diff --git a/hapsign/pipeline.py b/hapsign/pipeline.py index fa9f3c0..11cacf4 100644 --- a/hapsign/pipeline.py +++ b/hapsign/pipeline.py @@ -1,13 +1,13 @@ """登录、token 交换、签名材料申请、HAP 签名和安装的全流程编排。 -双重缓存策略(同一天内复用,避免反复登录和申请): - 1. Token 缓存:``~/.hapsign/.token_cache.json`` 存储当天登录的 token 信息, - 同账号同一天内复用,不重新登录。 +双重缓存策略(避免反复登录和申请): + 1. Token 缓存:``~/.hapsign/.token_cache.json`` 存储登录后的 token 信息, + 不按日期主动失效;仅在使用 token 的 API 请求被服务端拒绝时尝试刷新。 2. 签名文件缓存:``~/.hapsign/{bundle_name}/metadata.json`` 存储当天申请的 签名文件路径,同一天内复用,不重新申请证书/设备/Profile。 缓存失效场景: - - 跨天:token 和签名文件缓存都失效,重新登录 + 重新申请。 + - 跨天:签名文件缓存失效并重新申请;Token 缓存仍会先尝试复用。 - token 过期:缓存 token 用不了时自动刷新;刷新也失败则回退到重新登录。 - 签名文件缺失:重新申请(用缓存 token,不重新登录)。 """ @@ -184,11 +184,12 @@ def _emit_progress(self, value: int, label: str) -> None: # ── Token 缓存 ────────────────────────────────────────────── def _load_token_cache(self) -> dict | None: - """加载当天的 token 缓存。 + """加载 token 缓存。 - 条件:缓存存在、creation_date 是今天;缓存可能是 Windows DPAPI 加密格式 - 或受限权限的明文 JSON。Windows 上的明文缓存首次读取时迁移为加密格式; - 其他平台继续使用 0o600 明文缓存。解密失败视为无缓存,回退重新登录。 + 缓存不按 creation_date 主动失效;只有使用 token 的 API 请求被服务端拒绝 + 时才尝试刷新。缓存可能是 Windows DPAPI 加密格式或受限权限的明文 JSON。 + Windows 上的明文缓存首次读取时迁移为加密格式;其他平台继续使用 0o600 + 明文缓存。解密失败视为无缓存,回退重新登录。 """ if not os.path.exists(self._token_cache_path): return None @@ -226,9 +227,6 @@ def _load_token_cache(self) -> dict | None: logger.warning("[cache] token 缓存格式无效,将重新登录") return None - if cache.get("creation_date") != date.today().isoformat(): - logger.info("[cache] token 缓存非今日,跳过") - return None if ( not cache.get("access_token") or not cache.get("user_id") @@ -484,7 +482,7 @@ def _run_pipeline(self) -> bool: else: token_cached = None if token_cached: - logger.info("[cache] 当天 token 已缓存,跳过登录") + logger.info("[cache] token 已缓存,跳过登录") self._init_client_from_cache(token_cached) else: # 没有缓存 token,需要登录 @@ -610,7 +608,7 @@ def _step_exchange_token(self) -> bool: self._provision_api = ProvisionAPI(self._client) self._capability_api = CapabilityAPI(self._client) - # 保存 token 缓存,供同一天内复用 + # 保存 token 缓存,后续持续复用,直到服务端拒绝后触发刷新 return self._save_token_cache() def authenticate(self, force_refresh: bool = False) -> dict[str, object]: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 405b7df..c76b366 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -166,14 +166,17 @@ def test_custom_state_dir_owns_token_cache(tmp_path, monkeypatch) -> None: assert not (tmp_path / "signing_files" / ".token_cache.json").exists() -def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: +def test_older_token_cache_is_reused_until_server_rejects( + tmp_path, monkeypatch +) -> None: pipeline = _pipeline(tmp_path, monkeypatch) cache_path = tmp_path / "signing_files" / ".token_cache.json" cache_path.parent.mkdir() + creation_date = (date.today() - timedelta(days=1)).isoformat() cache_path.write_text( json.dumps( { - "creation_date": (date.today() - timedelta(days=1)).isoformat(), + "creation_date": creation_date, "access_token": "access", "user_id": "user", "jwt_token": "jwt", @@ -182,7 +185,11 @@ def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: encoding="utf-8", ) - assert pipeline._load_token_cache() is None + cache = pipeline._load_token_cache() + + assert cache is not None + assert cache["creation_date"] == creation_date + assert cache["access_token"] == "access" def test_token_cache_without_jwt_is_ignored(tmp_path, monkeypatch) -> None: @@ -279,8 +286,9 @@ def test_authenticate_reuses_token_without_browser_or_device( tmp_path, monkeypatch ) -> None: pipeline = _pipeline(tmp_path, monkeypatch) + creation_date = (date.today() - timedelta(days=1)).isoformat() cache = { - "creation_date": date.today().isoformat(), + "creation_date": creation_date, "access_token": "access", "refresh_token": "refresh", "user_id": "user", @@ -305,7 +313,7 @@ def test_authenticate_reuses_token_without_browser_or_device( assert result == { "authenticated": True, "from_cache": True, - "creation_date": date.today().isoformat(), + "creation_date": creation_date, } init_client.assert_called_once_with(cache)