diff --git a/cloud/README.md b/cloud/README.md index cb6d2c2..5986203 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -83,19 +83,56 @@ X-API-Key,因为外部服务无法持本站 API key,改靠各自的签名认 本站 API key——它的认证靠随机 state(10 分钟过期、一次性)加 client_secret(只走环境变量),不是无认证后门。 -### OAuth 接线步骤(脚手架,已实现,未接真实应用) +### OAuth 真实浏览器登录(已实现,部署前置仍需用户动手) -1. 注册真实 OAuth 应用(GitHub 或 Google),拿到 client_id, - 回调地址填本站的 `/oauth/{provider}/callback` 公网地址。 -2. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...` 拿 - authorization_url,把用户送到 provider 授权页。 -3. provider 回调本站并带回 code + state,调 - `POST /oauth/{provider}/callback`,验 state → 换 token → 映射 - 本站用户并记审计。client_secret 只从环境变量读 - (`GITHUB_OAUTH_CLIENT_SECRET` / `GOOGLE_OAUTH_CLIENT_SECRET`), - 绝不写文件、绝不进 URL、换 token 前未配好就明确报错。 -4. 仍缺:真实应用注册、回调公网地址、已有本站账号的绑定流程 - (目前是确定性用户名新建/复用,不做账号绑定)。 +流程(标准 OAuth2 授权码流程): +1. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...` + 返回 `{provider, state, authorization_url}`;state 已入库 + (10 分钟过期、一次性,provider / redirect_uri / client_id + 都随 state 一起存下),把用户送到 provider 授权页。 +2. 用户授权后 provider 重定向到本站 + `GET /oauth/{provider}/callback?code=...&state=...`(真实浏览器 + 流程只带 code + state;client_id / redirect_uri 由 server 从 + state 记录取回,不需要 provider 额外带回)。 +3. server 验 state → 用 code 换 token → 调真实 userinfo 取身份 + (GitHub:`https://api.github.com/user`,Bearer + User-Agent, + 取 login/id;Google:`https://openidconnect.googleapis.com/v1/userinfo`, + 取 sub/email/name)→ 映射到本站用户(确定性用户名 + `oauth::`,新建或复用)→ 签发本站 API key + 并记审计 `oauth_login`(轮换时另记 `api_key_rotated`)。 +4. 成功页(GET 回调)只显示 login、user_id、是否新用户和新 API + key 一次,并写清“请立即保存,刷新后不再显示,旧 key 已失效 + (如系轮换)”;成功页绝不显示 access_token、client_secret、 + 旧 API key。`POST /oauth/{provider}/callback`(JSON 版,body 带 + code/state,client_id/redirect_uri 可省、从 state 取回)同样在 + 成功响应里返回一次新 API key;之后任何接口都不再返回它。 + 失败一律 400:state 无效/过期/不匹配、换 token 失败、userinfo + 失败、client_secret 缺失都有明确 reason,且不建用户、不发 key。 + state 仍是一次性、10 分钟过期、provider / redirect_uri 必须匹配。 + +真实部署前置(以下任何一项没做,都不算真登录,绝不是“改个 +环境变量就自动有公网登录”): +- 在 GitHub 上注册 OAuth App(或 Google 上注册 OAuth 客户端): + 应用名自定;Homepage URL 填本站公网地址;Authorization + callback URL 必须与本站回调地址逐字一致,即 + `https://<你的域名>/oauth/github/callback`(Google 同理); + 非 localhost 时 GitHub 要求回调地址是 https,http 会被拒绝。 +- client_id 由登录请求方传入(login 接口的 query 参数不变); + client_secret 只从环境变量读(GitHub + `GITHUB_OAUTH_CLIENT_SECRET`、Google + `GOOGLE_OAUTH_CLIENT_SECRET`),绝不写文件、绝不硬编码、 + 绝不出现在授权 URL 或成功页里;secret 缺失时回调明确报 + `client_secret_missing`,不会以空 secret 去撞真实端点。 +- 本站控制面必须有公网可达的 https 回调地址:目前 server 默认 + 只绑 `127.0.0.1:8765`,没有公网入口、没有 TLS,这一步是部署 + 前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到 + server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。 +- 登录后 API key 的保存与轮换规则:新用户拿到创建时生成的 key; + 已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效), + 因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/ + 响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 key。 +- 仍缺(非部署前置,但同样未完成):已有本站账号的绑定流程 + (目前是确定性用户名新建/复用,不做账号绑定)。 ### 推送凭证清单 @@ -245,7 +282,8 @@ psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite, | `GET /projects/{id}/cost` | 该项目 `read` | 项目成本总览 + 按天:`?day=YYYY-MM-DD` 查单天,`?days=N` 查最近 N 天;无参数只给总览 | | `GET /models` | 认证即可 | 优先级列表(环境变量 `MODELS_PRIORITY` 逗号分隔,未设用默认)+ 只含调用者成员项目的模型统计与 fallback 事件(`fallback_count > 0` 的任务);不返回 prompt 内容 | | `GET /oauth/{provider}/login?client_id=&redirect_uri=&scope=` | 无需 X-API-Key(pre-auth) | 返回 `{provider, state, authorization_url}`,只返回 URL 不跳转;provider 仅 github/google,未知 `400`;state 已入库,10 分钟过期 | -| `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | body `{code, state, client_id, redirect_uri}`;验 state → 换 token → 映射到本站用户(复用 users 表,用户名 `oauth::`)并记审计 `oauth_login`;state 无效/过期、换 token 失败、无 secret 一律 `400` 且不建会话;成功不返回 API key(发放方式由调用方另行决定) | +| `GET /oauth/{provider}/callback?code=&state=` | 无需 X-API-Key(pre-auth) | 真实浏览器回调:验 state(从 state 记录取回 client_id / redirect_uri)→ 换 token → 真实 userinfo 取身份 → 映射本站用户并签发 API key;成功返回 HTML 成功页(login、user_id、是否新用户、新 API key 一次,含保存与轮换提示,不含 access_token / client_secret / 旧 key);state 无效/过期/不匹配、换 token 失败、userinfo 失败、无 secret 一律 `400` 错误页且不建用户、不发 key | +| `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | JSON 版回调:body `{code, state, client_id?, redirect_uri?}`(后两者可省、从 state 取回);验 state → 换 token → 真实 userinfo 取身份 → 映射到本站用户(复用 users 表,用户名 `oauth::`)并签发 API key、记审计 `oauth_login`(轮换时另记 `api_key_rotated`);state 无效/过期、换 token 失败、userinfo 失败、无 secret 一律 `400` 且不建用户、不发 key;成功响应里返回一次新 API key,之后任何接口都不再返回它 | | `POST /projects/{id}/push/subscriptions` | 该项目 `create_task` | 记订阅 body `{channel, target, preference?}`,channel 为 webhook/slack/webpush/apns/fcm,preference 为 instant/digest/silent,返回 `201` | | `GET /projects/{id}/push/subscriptions` | 该项目 `read` | 只列调用者自己的订阅(不泄露他人 target) | | `DELETE /projects/{id}/push/subscriptions/{sid}` | 该项目 `create_task` | 只能删自己的订阅,项目 owner 可删任意;不存在 `404`,他人订阅 `403` | @@ -338,12 +376,14 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳 Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key 写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker 的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。 -- **其它待接线(非外部条件,但同样未完成)**:OAuth 已接成脚手架 - 端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、 - 已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push / - APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下 - 仍有上限,生产量上来后需换 Postgres(Postgres 后端已接通, - 但真实服务器部署与验证仍缺,见上文“数据库后端选择”); +- **其它待接线(非外部条件,但同样未完成)**:OAuth 真实浏览器 + 登录已接通(GET 回调 + 真实 userinfo + 回调签发 API key),但 + 真实应用注册、回调公网 https 入口、已有账号绑定仍缺(见上文 + “OAuth 真实浏览器登录”一节的部署前置);推送已接成订阅 + + 分发端点,但 Web Push / APNs / FCM 的真实凭证与发送端仍缺; + 单机 SQLite 在高并发写下仍有上限,生产量上来后需换 Postgres + (Postgres 后端已接通,真实服务器部署与验证见上文“数据库后端 + 选择”); worker 执行已接通(见下文“worker 常驻与真取消”),云端执行、 真实通知与真实模型回合仍受 key 供给与云账号卡点限制。 diff --git a/cloud/auth.py b/cloud/auth.py index 0943998..d5d81e8 100644 --- a/cloud/auth.py +++ b/cloud/auth.py @@ -84,6 +84,26 @@ class AuthStore: self.conn.commit() return user_id, api_key_plain + def rotate_api_key(self, user_id): + """轮换用户的 API key,返回新明文 key(只返回这一次)。 + + 旧 key 的哈希即被替换,轮换后旧 key 立即失效。 + 用户不存在时抛 ValueError(调用方不得用它凭空建用户)。 + """ + row = self.conn.execute( + "SELECT id FROM users WHERE id = ?", (user_id,) + ).fetchone() + if row is None: + raise ValueError("unknown user: %r" % (user_id,)) + api_key_plain = secrets.token_urlsafe(32) + api_key_hash = hashlib.sha256(api_key_plain.encode()).hexdigest() + self.conn.execute( + "UPDATE users SET api_key_hash = ? WHERE id = ?", + (api_key_hash, user_id), + ) + self.conn.commit() + return api_key_plain + def authenticate(self, api_key): """用 API key 认证,成功返回 user_id,失败返回 None。""" if not api_key: diff --git a/cloud/oauth.py b/cloud/oauth.py index ff40ea1..2df8954 100644 --- a/cloud/oauth.py +++ b/cloud/oauth.py @@ -1,23 +1,25 @@ -"""OAuth 接线脚手架(GitHub / Google,首版)。 +"""OAuth 接线(GitHub / Google)。 -诚实边界:这只是脚手架,不是已接通的真实登录。 -- 本模块不向真实 GitHub / Google 发任何请求:默认的 HTTP 路径 - (``_urllib_post``)只在调用方真的传入真实 code 且配好 - client_secret 时才会被走到;单测一律用注入的 fake http 客户端。 +真实浏览器登录已接通:server 层提供 ``GET /oauth/{provider}/callback`` +(provider 重定向带回 code + state),回调验 state、换 token、 +调真实 userinfo 取身份、映射到本站用户并签发本站 API key。 +单测一律用注入的 fake http 客户端,不向真实 provider 发请求。 + +诚实边界(仍未做的部分,见 cloud/README.md 的 OAuth 一节): - 本模块不硬编码任何真实 client_id / client_secret:client_id 由 - 调用方以参数传入,client_secret 只从环境变量读 + 登录请求方传入并随 state 一起入库(回调时从 state 记录取回, + 这样浏览器重定向只带 code + state 也能完成换 token), + client_secret 只从环境变量读 (GitHub:``GITHUB_OAUTH_CLIENT_SECRET``, Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不 - 硬编码、绝不出现在授权 URL 里。 + 硬编码、绝不出现在授权 URL 或成功页里。 - 本模块不存储 access_token:回调成功后只返回外部身份信息, access_token 在提取身份后即丢弃。如后续需要用 token 调 provider API,需另设计加密存储,本模块不提供。 -- 本模块不创建本站会话:回调成功返回的只是外部身份字典, - 映射到本站用户、建会话、记审计由 server 层用 ``auth.AuthStore`` - 完成(见 ``handle_oauth_callback`` 文档里的 server 层示例)。 - 附带的 ``map_external_identity_to_local_user`` 只复用 auth.py 已有 - 的 users 表(按确定性用户名查找/创建),不发明新的身份表, - 也不发明新的审计格式。 +- 本模块不做已有本站账号的绑定:映射仍是确定性用户名 + ``oauth::`` 新建/复用,绑定流程需另加。 +- 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1, + 真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。 流程(标准 OAuth2 授权码流程的接线点): state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期) @@ -49,12 +51,14 @@ PROVIDERS = { "github": { "authorize_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token", + "userinfo_url": "https://api.github.com/user", "default_scope": "read:user", "client_secret_env": "GITHUB_OAUTH_CLIENT_SECRET", }, "google": { "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", + "userinfo_url": "https://openidconnect.googleapis.com/v1/userinfo", "default_scope": "openid email profile", "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", }, @@ -67,7 +71,7 @@ class OAuthError(Exception): ``reason`` 供调用方区分(例如记日志或返回不同的错误提示), 取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` / ``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed`` - / ``identity_missing``。 + / ``identity_missing`` / ``userinfo_failed`` / ``client_id_missing``。 """ def __init__(self, message, reason=None): @@ -103,26 +107,40 @@ class OAuthStateStore: "state TEXT PRIMARY KEY, " "provider TEXT, " "redirect_uri TEXT, " + "client_id TEXT, " "created_at REAL)" ) + # 老库迁移:脚手架版的表没有 client_id 列,缺了就补上。 + # client_id 随 state 入库,是真实浏览器流程的关键:provider + # 重定向只带回 code + state,回调靠 state 记录取回 client_id + # 才能换 token;补列是 no-op(已有的库再跑一次不变)。 + cols = {row[1] for row in self.conn.execute( + "PRAGMA table_info(oauth_states)").fetchall()} + if "client_id" not in cols: + self.conn.execute( + "ALTER TABLE oauth_states ADD COLUMN client_id TEXT") self.conn.commit() - def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS): + def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS, + client_id=None): """生成并入库一个随机 state,返回 state 字符串。 ``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定, 本函数不把过期时间单独存一列:判定统一用 ``created_at + ttl_seconds``(validate 时传入的 ttl 须与 创建时一致,默认两边都是 600 秒)。 + ``client_id`` 随 state 一起入库:真实浏览器回调只带 + code + state,回调方靠它取回登录时传入的 client_id。 """ _provider_config(provider) if not redirect_uri or not isinstance(redirect_uri, str): raise ValueError("redirect_uri required") state = secrets.token_urlsafe(32) self.conn.execute( - "INSERT INTO oauth_states(state, provider, redirect_uri, created_at)" - " VALUES(?, ?, ?, ?)", - (state, provider, redirect_uri, time.time()), + "INSERT INTO oauth_states(state, provider, redirect_uri," + " client_id, created_at)" + " VALUES(?, ?, ?, ?, ?)", + (state, provider, redirect_uri, client_id, time.time()), ) self.conn.commit() return state @@ -148,13 +166,13 @@ class OAuthStateStore: if now is None: now = time.time() row = self.conn.execute( - "SELECT state, provider, redirect_uri, created_at" + "SELECT state, provider, redirect_uri, client_id, created_at" " FROM oauth_states WHERE state = ?", (state,), ).fetchone() if row is None: return None - stored_state, stored_provider, stored_redirect_uri, created_at = row + stored_state, stored_provider, stored_redirect_uri, stored_client_id, created_at = row if stored_provider != provider: return None if redirect_uri is not None and stored_redirect_uri != redirect_uri: @@ -171,6 +189,7 @@ class OAuthStateStore: "state": stored_state, "provider": stored_provider, "redirect_uri": stored_redirect_uri, + "client_id": stored_client_id, "created_at": created_at, } @@ -217,8 +236,10 @@ def start_oauth_login(state_store, provider, client_id, redirect_uri, 返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL, provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。 + client_id 随 state 一起入库,回调时从 state 记录取回。 """ - state = state_store.create_state(provider, redirect_uri) + state = state_store.create_state(provider, redirect_uri, + client_id=client_id) url = build_authorization_url( provider, client_id, redirect_uri, scope=scope, state=state) return state, url @@ -310,15 +331,72 @@ def exchange_code_for_token(provider, code, client_id, redirect_uri, return result +def _urllib_get_json(url, headers): + """默认的真实 userinfo 实现(urllib GET,返回解析后的 dict)。 + + 非 2xx、非法 JSON、非 dict 一律抛 OAuthError(reason=userinfo_failed), + 调用方不得把失败静默当成匿名登录。 + """ + request = urllib.request.Request(url, method="GET", headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8", "replace") + status = response.status + except Exception as exc: + raise OAuthError( + "userinfo request failed: %s" % (exc,), + reason="userinfo_failed", + ) from exc + if not (200 <= status < 300): + raise OAuthError( + "userinfo request returned HTTP %s" % status, + reason="userinfo_failed", + ) + try: + parsed = json.loads(raw) + except ValueError as exc: + raise OAuthError( + "userinfo response is not JSON", reason="userinfo_failed", + ) from exc + if not isinstance(parsed, dict): + raise OAuthError( + "userinfo response is not an object", reason="userinfo_failed") + return parsed + + +def default_userinfo_client(provider): + """返回该 provider 的真实 userinfo 调用(access_token -> dict)。 + + GitHub 调 https://api.github.com/user(Bearer + User-Agent)取 + login/id;Google 调 https://openidconnect.googleapis.com/v1/userinfo + 取 sub/email/name。失败一律抛 OAuthError(reason=userinfo_failed)。 + """ + + config = _provider_config(provider) + url = config["userinfo_url"] + + def fetch(access_token): + headers = {"Authorization": "Bearer %s" % access_token, + "Accept": "application/json"} + if provider == "github": + # GitHub 要求 User-Agent,否则可能直接 403。 + headers["User-Agent"] = "codex-app-server" + return _urllib_get_json(url, headers) + + return fetch + + def _identity_from_token_or_userinfo(provider, token_result, userinfo_client): """从 token 响应或 userinfo 回调里抽外部身份。 顺序:先看 token 响应自带的身份字段(fake / 某些 provider 会带 - id、login、email),没有则调 userinfo_client(access_token); - 两边都没有可用身份时返回 None。真实 GitHub 的 token 响应不带 - 身份,生产调用必须传 userinfo_client(例如调 - https://api.github.com/user 并取 login/id);真实 Google 的 - id_token 解析同样走 userinfo_client 这一注入点,本模块不解析 JWT。 + id、login、email),没有则调 userinfo_client(access_token)。 + userinfo_client 已传入但调用失败、返回非 dict、或无可用身份字段时, + 抛 OAuthError(reason=userinfo_failed),绝不静默当成匿名登录; + 只有 userinfo_client 本就没传时才返回 None(调用方再判为 + identity_missing)。真实 GitHub 的 token 响应不带身份,生产 + 调用必须传 userinfo_client;真实 Google 的 id_token 解析同样 + 走 userinfo_client 这一注入点,本模块不解析 JWT。 """ for source in (token_result,): external_id = source.get("id") or source.get("sub") @@ -335,15 +413,24 @@ def _identity_from_token_or_userinfo(provider, token_result, userinfo_client): return None try: info = userinfo_client(token_result["access_token"]) - except Exception: - return None + except OAuthError: + raise + except Exception as exc: + raise OAuthError( + "userinfo request failed: %s" % (exc,), + reason="userinfo_failed", + ) from exc if not isinstance(info, dict): - return None + raise OAuthError( + "userinfo response is not an object", reason="userinfo_failed") external_id = info.get("id") or info.get("sub") login = info.get("login") or info.get("name") email = info.get("email") if external_id is None and login is None and email is None: - return None + raise OAuthError( + "userinfo returned no usable identity fields", + reason="userinfo_failed", + ) return { "provider": provider, "external_id": str(external_id) if external_id is not None else None, @@ -368,7 +455,12 @@ def handle_oauth_callback(state_store, provider, code, state, client_id, - state 与 provider / redirect_uri 不匹配 -> reason=state_mismatch; - 换 token 失败 -> reason=token_exchange_failed; - - 拿不到外部身份 -> reason=identity_missing。 + - userinfo 已传但失败/无可用身份 -> reason=userinfo_failed; + - 未传 userinfo_client 且 token 本身无身份 -> reason=identity_missing; + - state 里没有 client_id 且调用方也没传 -> reason=client_id_missing。 + + client_id / redirect_uri 未传时从 state 记录取回(见上文), + 因此真实浏览器回调只带 code + state 也能完成。 本函数 deliberately 不做以下三件事,全部留给 server 层(签名 与 auth.py 完全兼容,不发明新格式): @@ -392,17 +484,27 @@ def handle_oauth_callback(state_store, provider, code, state, client_id, if now is None: now = time.time() peek = state_store.conn.execute( - "SELECT provider, redirect_uri, created_at" + "SELECT provider, redirect_uri, client_id, created_at" " FROM oauth_states WHERE state = ?", (state,), ).fetchone() if state else None if peek is None: raise OAuthError("unknown state", reason="state_not_found") - peek_provider, peek_redirect_uri, peek_created_at = peek + peek_provider, peek_redirect_uri, peek_client_id, peek_created_at = peek + # 真实浏览器回调只带 code + state:client_id / redirect_uri 未传 + # 时从 state 记录取回(登录时已随 state 入库);显式传入的优先。 + if client_id is None: + client_id = peek_client_id + if redirect_uri is None: + redirect_uri = peek_redirect_uri if peek_provider != provider or ( redirect_uri is not None and peek_redirect_uri != redirect_uri): raise OAuthError("state does not match provider/redirect_uri", reason="state_mismatch") + if not client_id: + raise OAuthError( + "client_id not recorded for this state; please restart login", + reason="client_id_missing") if now > peek_created_at + ttl_seconds: state_store.conn.execute( "DELETE FROM oauth_states WHERE state = ?", (state,)) @@ -460,3 +562,36 @@ def map_external_identity_to_local_user(auth_store, provider, identity): return row[0], False user_id, _api_key_plain = auth_store.create_user(username) return user_id, True + + +def map_external_identity_to_local_user_with_key(auth_store, provider, + identity): + """映射到本站用户并签发本站 API key(明文只返回这一次)。 + + 返回 ``(user_id, created, api_key_plain, rotated)``: + - 新用户:经 ``AuthStore.create_user`` 创建,返回创建时生成的 + key,``rotated`` 为 False; + - 已存在用户:经 ``AuthStore.rotate_api_key`` 轮换,返回新 key, + 旧 key 即失效,``rotated`` 为 True; + 调用方须把明文 key 只在回调成功页/响应里展示一次,之后任何 + 接口都不再返回它;审计由 server 层记(oauth_login 恒记, + 轮换时另记 api_key_rotated)。 + """ + _provider_config(provider) + login = identity.get("login") or identity.get("external_id") \ + or identity.get("email") + if login is None: + raise OAuthError( + "identity has no login/external_id/email to map from", + reason="identity_missing", + ) + username = "oauth:%s:%s" % (provider, login) + row = auth_store.conn.execute( + "SELECT id FROM users WHERE username = ?", (username,) + ).fetchone() + if row is None: + # 新用户:直接返回创建时生成的 key,不再多轮换一次。 + user_id, api_key_plain = auth_store.create_user(username) + return user_id, True, api_key_plain, False + api_key_plain = auth_store.rotate_api_key(row[0]) + return row[0], False, api_key_plain, True diff --git a/cloud/server.py b/cloud/server.py index 819385a..0b4778c 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -37,6 +37,7 @@ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突 """ import argparse +import html import json import os import sqlite3 @@ -58,8 +59,9 @@ from cloud.memory import MemoryStore from cloud.oauth import ( OAuthError, OAuthStateStore, + default_userinfo_client, handle_oauth_callback, - map_external_identity_to_local_user, + map_external_identity_to_local_user_with_key, start_oauth_login, ) from cloud.push import ( @@ -280,6 +282,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _send_html(self, status, html_body): + body = html_body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def _read_body(self): try: length = int(self.headers.get("Content-Length") or 0) @@ -341,6 +351,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): # 认证,不走 X-API-Key;不要把它当成无认证后门。 if method == "GET" and path.endswith("/login"): self._oauth_login(path) + elif method == "GET" and path.endswith("/callback"): + # 真实浏览器流程:provider 重定向到这里,query 带 + # code + state(不带 client_id,靠 state 记录取回)。 + self._oauth_callback_get(path) elif method == "POST" and path.endswith("/callback"): self._oauth_callback(path) else: @@ -1209,6 +1223,51 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self._send_json(200, {"provider": provider, "state": state, "authorization_url": url}) + def _oauth_clients(self, provider): + """回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。 + + server 属性 oauth_http_client / oauth_userinfo_client 由 + create_server 置为 None(生产)或由测试注入 fake;http_client + 已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake + token 自带身份字段时不需要它,走真实端点反而会用假 token + 撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。 + """ + http_client = getattr(self.server, "oauth_http_client", None) + userinfo_client = getattr(self.server, "oauth_userinfo_client", None) + if userinfo_client is None and http_client is None: + userinfo_client = default_userinfo_client(provider) + return http_client, userinfo_client + + def _oauth_finish(self, provider, code, state, client_id, redirect_uri): + """回调共用后半段:验 state -> 换 token -> 取身份 -> 发本站 key。 + + 成功返回 (local_id, created, api_key_plain, rotated, identity); + 失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把 + access_token、client_secret、旧 API key 写进任何响应)。 + client_id / redirect_uri 为 None 时由 handle_oauth_callback + 从 state 记录取回(真实浏览器回调只带 code + state)。 + """ + store = OAuthStateStore(self.server.db_path) + try: + http_client, userinfo_client = self._oauth_clients(provider) + identity = handle_oauth_callback( + store, provider, code, state, client_id, redirect_uri, + http_client=http_client, userinfo_client=userinfo_client) + finally: + try: + store.conn.close() + except Exception: + pass + with _auth_store(self.server.db_path) as auth: + local_id, created, api_key_plain, rotated = \ + map_external_identity_to_local_user_with_key( + auth, provider, identity) + auth.audit(local_id, "", "oauth_login", + "%s:%s" % (provider, identity.get("external_id"))) + if rotated: + auth.audit(local_id, "", "api_key_rotated", provider) + return local_id, created, api_key_plain, rotated, identity + def _oauth_callback(self, path): provider = self._oauth_provider(path) if provider is None: @@ -1222,30 +1281,96 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): state = body.get("state") client_id = body.get("client_id") redirect_uri = body.get("redirect_uri") - if not code or not state or not client_id or not redirect_uri: - self._send_json(400, {"error": "code, state, client_id and redirect_uri required"}) + if not code or not state: + self._send_json(400, {"error": "code and state required"}) return - store = OAuthStateStore(self.server.db_path) try: - identity = handle_oauth_callback( - store, provider, code, state, client_id, redirect_uri) + local_id, created, api_key_plain, rotated, identity = \ + self._oauth_finish(provider, code, state, client_id, + redirect_uri) except OAuthError as e: self._send_json(400, {"error": str(e), "reason": e.reason}) return - finally: - try: - store.conn.close() - except Exception: - pass - with _auth_store(self.server.db_path) as auth: - local_id, created = map_external_identity_to_local_user( - auth, provider, identity) - auth.audit(local_id, "", "oauth_login", - "%s:%s" % (provider, identity.get("external_id"))) + # api_key 明文只在这里出现一次:调用方须立即保存, + # 之后任何接口都不再返回它;轮换时旧 key 已失效。 self._send_json(200, {"user_id": local_id, "created": created, "provider": provider, "login": identity.get("login"), - "email": identity.get("email")}) + "email": identity.get("email"), + "api_key": api_key_plain, + "api_key_rotated": rotated}) + + def _oauth_error_page(self, reason, message): + """GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、 + token 或旧 API key(message 来自 OAuthError,本身不含密钥, + 仍做 HTML 转义)。""" + return ( + "" + "OAuth login failed" + "

OAuth login failed

" + "

reason: %s

%s

" + "

Please restart login from the login page.

" + "" + % (html.escape(str(reason or "error")), + html.escape(str(message)))) + + def _oauth_success_page(self, provider, local_id, created, login, + api_key_plain, rotated): + """GET 回调的成功页:只显示 login、user_id、created 与新 API key + 一次;绝不显示 access_token、client_secret、旧 API key。""" + rotation_note = ( + "

Your previous API key has been rotated and no longer works.

" + if rotated else "") + return ( + "" + "OAuth login succeeded" + "

OAuth login succeeded

" + "

provider: %s

" + "

login: %s

" + "

user_id: %s

" + "

new user: %s

" + "%s" + "

Your API key (shown only once, save it now):

" + "

%s

" + "

Please save it immediately. It will not be shown again " + "after you leave or refresh this page." + "%s

" + "

Use it as the X-API-Key header for API calls.

" + "" + % (html.escape(str(provider)), + html.escape(str(login)), + html.escape(str(local_id)), + "yes" if created else "no", + rotation_note, + html.escape(str(api_key_plain)), + " Your old key has expired (rotated)." if rotated else "")) + + def _oauth_callback_get(self, path): + provider = self._oauth_provider(path) + if provider is None: + self._send_html(404, self._oauth_error_page( + "not_found", "unknown oauth path")) + return + query = parse_qs(urlparse(self.path).query) + code = (query.get("code") or [None])[0] + state = (query.get("state") or [None])[0] + client_id = (query.get("client_id") or [None])[0] + redirect_uri = (query.get("redirect_uri") or [None])[0] + if not code or not state: + self._send_html(400, self._oauth_error_page( + "missing_params", "code and state required")) + return + try: + local_id, created, api_key_plain, rotated, identity = \ + self._oauth_finish(provider, code, state, client_id, + redirect_uri) + except OAuthError as e: + self._send_html(400, self._oauth_error_page( + e.reason or "oauth_failed", str(e))) + return + self._send_html(200, self._oauth_success_page( + provider, local_id, created, identity.get("login"), + api_key_plain, rotated)) # -- 推送订阅与分发 --------------------------------------------------- def _push_create_endpoint(self, user_id, project_id): @@ -1363,6 +1488,10 @@ def create_server(db_path, host="127.0.0.1", port=8765, backend=None): server = ThreadingHTTPServer((host, port), CloudControlPlaneHandler) server.db_path = db_path server.daemon_threads = True + # OAuth 回调注入点:生产为 None(走真实换 token + 真实 userinfo), + # 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。 + server.oauth_http_client = None + server.oauth_userinfo_client = None return server diff --git a/cloud/tests/test_oauth_real_login.py b/cloud/tests/test_oauth_real_login.py new file mode 100644 index 0000000..522c03b --- /dev/null +++ b/cloud/tests/test_oauth_real_login.py @@ -0,0 +1,244 @@ +"""OAuth 真实浏览器登录的 HTTP 端点测试。 + +不联网、不向真实 GitHub/Google 发请求:server 的注入点 +(server.oauth_http_client / server.oauth_userinfo_client)挂 fake; +真实 userinfo 端点本身只由 cloud.oauth.default_userinfo_client +拼 URL,不在这里调真实网络。测试里出现的 token、key 一律是 +fake 占位,绝无真实 client_secret、真实 token、真实 GitHub 账号。 +""" + +import http.client +import os +import re +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +try: + from cloud.auth import AuthStore + from cloud.oauth import OAuthStateStore + from cloud.server import create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.oauth import OAuthStateStore + from cloud.server import create_server + + +def _fake_token_client(result=None, error=None): + def fake(token_url, payload): + if error is not None: + return {"error": error} + return dict(result if result is not None else + {"access_token": "fake-token-not-real"}) + return fake + + +def _fake_userinfo(result=None, exc=None): + def fake(access_token): + if exc is not None: + raise exc + return dict(result if result is not None else + {"id": 4242, "login": "gh-user", + "email": "gh-user@example.invalid"}) + return fake + + +class OAuthRealLoginTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + # 默认挂 fake:token 响应故意不带身份字段,逼走 userinfo 路径。 + self.server.oauth_http_client = _fake_token_client() + self.server.oauth_userinfo_client = _fake_userinfo() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + try: + headers = {} + payload = None + if body is not None: + import json + payload = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + conn.request(method, path, body=payload, headers=headers) + resp = conn.getresponse() + data = resp.read().decode("utf-8", "replace") + finally: + conn.close() + return resp.status, data + + def _login_state(self): + status, _ = self._req( + "GET", + "/oauth/github/login?client_id=test-client" + "&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb") + self.assertEqual(status, 200) + store = OAuthStateStore(self.db_path) + try: + row = store.conn.execute( + "SELECT state FROM oauth_states ORDER BY created_at DESC" + " LIMIT 1").fetchone() + finally: + store.conn.close() + return row[0] + + def _api_key_in_page(self, page): + match = re.search(r"([^<]+)", page) + self.assertIsNotNone(match, page) + return match.group(1) + + # -- GET 回调成功:发 key、key 可用、轮换旧 key 失效 ------------- + def test_get_callback_success_issues_key_and_rotation(self): + state = self._login_state() + status, page = self._req( + "GET", "/oauth/github/callback?code=fake-code&state=" + state) + self.assertEqual(status, 200, page) + self.assertIn("gh-user", page) + self.assertIn("new user: yes", page) + self.assertNotIn("fake-token-not-real", page) + self.assertNotIn("client_secret", page) + key1 = self._api_key_in_page(page) + user_id = self.auth.authenticate(key1) + self.assertIsNotNone(user_id) + + # 同一身份第二次登录:新 key、旧 key 失效、审计有轮换记录。 + state2 = self._login_state() + status, page2 = self._req( + "GET", "/oauth/github/callback?code=fake-code&state=" + state2) + self.assertEqual(status, 200, page2) + self.assertIn("new user: no", page2) + self.assertIn("rotated", page2) + key2 = self._api_key_in_page(page2) + self.assertNotEqual(key1, key2) + self.assertIsNone(self.auth.authenticate(key1)) + self.assertEqual(self.auth.authenticate(key2), user_id) + actions = [row["action"] for row in self.auth.list_audit("", limit=50)] + self.assertIn("oauth_login", actions) + self.assertIn("api_key_rotated", actions) + + def test_post_callback_success_returns_key_once(self): + state = self._login_state() + import json + status, body = self._req( + "POST", "/oauth/github/callback", + {"code": "fake-code", "state": state, + "client_id": "test-client", + "redirect_uri": "http://127.0.0.1/cb"}) + parsed = json.loads(body) + self.assertEqual(status, 200, body) + self.assertIn("api_key", parsed) + self.assertFalse(parsed["api_key_rotated"]) + self.assertEqual(self.auth.authenticate(parsed["api_key"]), + parsed["user_id"]) + + # -- GET 回调失败路径 ---------------------------------------------- + def test_get_callback_bad_state_400_no_user(self): + status, page = self._req( + "GET", "/oauth/github/callback?code=x&state=no-such-state") + self.assertEqual(status, 400) + self.assertNotIn("fake-token-not-real", page) + count = self.auth.conn.execute( + "SELECT COUNT(*) FROM users").fetchone()[0] + self.assertEqual(count, 0) + + def test_get_callback_expired_state_400(self): + state = self._login_state() + store = OAuthStateStore(self.db_path) + try: + store.conn.execute( + "UPDATE oauth_states SET created_at = ? WHERE state = ?", + (time.time() - 601, state)) + store.conn.commit() + finally: + store.conn.close() + status, _ = self._req( + "GET", "/oauth/github/callback?code=x&state=" + state) + self.assertEqual(status, 400) + + def test_get_callback_state_provider_mismatch_400(self): + state = self._login_state() + status, _ = self._req( + "GET", "/oauth/google/callback?code=x&state=" + state) + self.assertEqual(status, 400) + + def test_get_callback_token_exchange_failure_400_no_user(self): + self.server.oauth_http_client = _fake_token_client( + error="bad_verification_code") + state = self._login_state() + status, _ = self._req( + "GET", "/oauth/github/callback?code=bad&state=" + state) + self.assertEqual(status, 400) + count = self.auth.conn.execute( + "SELECT COUNT(*) FROM users").fetchone()[0] + self.assertEqual(count, 0) + + def test_get_callback_userinfo_failure_400_no_user(self): + self.server.oauth_userinfo_client = _fake_userinfo(exc=RuntimeError("down")) + state = self._login_state() + status, page = self._req( + "GET", "/oauth/github/callback?code=x&state=" + state) + self.assertEqual(status, 400, page) + self.assertIn("userinfo", page) + count = self.auth.conn.execute( + "SELECT COUNT(*) FROM users").fetchone()[0] + self.assertEqual(count, 0) + + def test_get_callback_userinfo_empty_400_no_user(self): + self.server.oauth_userinfo_client = _fake_userinfo(result={}) + state = self._login_state() + status, _ = self._req( + "GET", "/oauth/github/callback?code=x&state=" + state) + self.assertEqual(status, 400) + count = self.auth.conn.execute( + "SELECT COUNT(*) FROM users").fetchone()[0] + self.assertEqual(count, 0) + + def test_get_callback_secret_missing_400_before_network(self): + # 真实路径(不注入 fake):无 secret 时换 token 前就拒绝, + # 不会走到真实网络;fake userinfo 在此路径下不被使用。 + self.server.oauth_http_client = None + self.server.oauth_userinfo_client = None + old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None) + try: + state = self._login_state() + status, page = self._req( + "GET", "/oauth/github/callback?code=x&state=" + state) + finally: + if old is not None: + os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old + self.assertEqual(status, 400, page) + self.assertIn("client_secret", page) + + # -- AuthStore 轮换本身 --------------------------------------------- + def test_rotate_api_key_old_key_dead(self): + user_id, old_key = self.auth.create_user("someone") + new_key = self.auth.rotate_api_key(user_id) + self.assertNotEqual(old_key, new_key) + self.assertIsNone(self.auth.authenticate(old_key)) + self.assertEqual(self.auth.authenticate(new_key), user_id) + with self.assertRaises(ValueError): + self.auth.rotate_api_key("no-such-user") + + +if __name__ == "__main__": + unittest.main()