"""Handler mixin:OAuthMixin。 由 CloudControlPlaneHandler 拆出,方法体逐字搬移; self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等) 在混入后的同一类上解析,行为不变。本模块不 import cloud.server。 """ from cloud.github_app import ( DEFAULT_APP_NAME, DEFAULT_APP_URL, AppError, SetupStateStore, build_app_manifest, exchange_manifest_code, list_installations, public_app_status, save_app_config, ) from cloud.oauth import ( BindError, OAuthError, OAuthStateStore, bind_external_identity, build_authorization_url, default_userinfo_client, find_bound_user, handle_oauth_callback, list_bindings, map_external_identity_to_local_user_with_key, start_oauth_login, unbind_external_identity, ) from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key from cloud.sub2api import gateway_origin as _gateway_origin import html import json import os from urllib.parse import parse_qs, quote, urlparse from cloud.handler_support import ( _apply_default_quota as _apply_default_quota, _auth_store as _auth_store, _default_quota_usd_micros as _default_quota_usd_micros, _global_admin_ids as _global_admin_ids, ) class OAuthMixin: """OAuthMixin 领域方法(见模块 docstring)。""" # -- OAuth(pre-auth,靠 state + secret,不走 X-API-Key)----------- def _oauth_provider(self, path): parts = [p for p in path.split("/") if p] if len(parts) != 3 or parts[0] != "oauth": return None return parts[1] def _oauth_login(self, path): provider = self._oauth_provider(path) if provider is None: self._send_json(404, {"error": "not found"}) return query = parse_qs(urlparse(self.path).query) client_id = (query.get("client_id") or [None])[0] redirect_uri = (query.get("redirect_uri") or [None])[0] scope = (query.get("scope") or [None])[0] if not client_id or not redirect_uri: self._send_json(400, {"error": "client_id and redirect_uri required"}) return store = OAuthStateStore(self.server.db_path) try: state, url = start_oauth_login( store, provider, client_id, redirect_uri, scope=scope) except OAuthError as e: self._send_json(400, {"error": str(e), "reason": e.reason}) return except ValueError as e: self._send_json(400, {"error": str(e)}) return finally: try: store.conn.close() except Exception: pass # 只返回 URL,不直接跳转到任何登录页;跳转由调用方决定。 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 -> 取身份 -> 分叉。 成功返回 6 元组 ``(local_id, created, api_key_plain, rotated, identity, is_bind)``; 失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把 access_token、client_secret、旧 API key 写进任何响应), 绑定冲突抛 BindError(调用方按 409 呈现)。 client_id / redirect_uri 为 None 时由 handle_oauth_callback 从 state 记录取回(真实浏览器回调只带 code + state)。 分叉规则(顺序即优先级): 1. state 带 bind_user_id -> 绑定分支:把身份绑到该本站 账号,返回 is_bind=True、api_key_plain=None(绑定成功页 绝不显示任何 key);冲突抛 BindError; 2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按 OAuth 登录规矩签发附加 key(created=False、rotated 恒为 False),与 oauth 账号登录同一语义,不发明新语义; 3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 签发 附加 key)。 """ 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: bind_user_id = identity.get("bind_user_id") if bind_user_id: binding = bind_external_identity( auth, provider, identity, bind_user_id) auth.audit(bind_user_id, "", "bind_external", "%s:%s" % (provider, identity.get("external_id"))) if _apply_default_quota(auth.conn, bind_user_id): auth.audit(bind_user_id, "", "quota_default", "quota_usd_micros=%d" % _default_quota_usd_micros()) identity["already_bound"] = binding["already"] return (bind_user_id, False, None, False, identity, True) bound_user_id = find_bound_user(auth, provider, identity) if bound_user_id is not None: _key_id, api_key_plain = auth.create_api_key( bound_user_id, "oauth:%s" % provider) auth.audit(bound_user_id, "", "oauth_login", "%s:%s" % (provider, identity.get("external_id"))) auth.audit(bound_user_id, "", "api_key_issued", provider) if _apply_default_quota(auth.conn, bound_user_id): auth.audit(bound_user_id, "", "quota_default", "quota_usd_micros=%d" % _default_quota_usd_micros()) return (bound_user_id, False, api_key_plain, False, identity, False) 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 _apply_default_quota(auth.conn, local_id): auth.audit(local_id, "", "quota_default", "quota_usd_micros=%d" % _default_quota_usd_micros()) if created and _gateway_origin() is not None: # sub2api 开通(best-effort):建号不因网关开通失败 # 而失败,任务时 _ensure_gateway 会懒开通兜底。 try: _ensure_sub2api_key(auth.conn, local_id) except Exception as exc: auth.audit(local_id, "", "sub2api_provision_failed", str(exc)[:200]) if not created: auth.audit(local_id, "", "api_key_issued", provider) return local_id, created, api_key_plain, rotated, identity, False def _oauth_callback(self, path): provider = self._oauth_provider(path) if provider is None: self._send_json(404, {"error": "not found"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return code = body.get("code") state = body.get("state") client_id = body.get("client_id") redirect_uri = body.get("redirect_uri") if not code or not state: self._send_json(400, {"error": "code and state required"}) return try: local_id, created, api_key_plain, rotated, identity, is_bind = \ self._oauth_finish(provider, code, state, client_id, redirect_uri) except BindError as e: self._send_json(409, {"error": str(e), "reason": e.reason}) return except OAuthError as e: self._send_json(400, {"error": str(e), "reason": e.reason}) return if is_bind: # 绑定成功:绝不返回任何 key(绑定页的 completer 未必是 # 发起人,key 一旦上页就可能落到别人手里);发起人用 # 自己的 key 调 GET /oauth/{provider}/bindings 核对。 self._send_json(200, {"linked": True, "provider": provider, "login": identity.get("login"), "external_id": identity.get("external_id"), "user_id": local_id, "already_bound": identity.get("already_bound", False)}) return # api_key 明文只在这里出现一次:调用方须立即保存, # 之后任何接口都不再返回它;登录签发的是附加 key,旧 key # 仍可继续使用(多 key;不用的旧 key 可在 keys 管理里撤销)。 self._send_json(200, {"user_id": local_id, "created": created, "provider": provider, "login": identity.get("login"), "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 ( "
" "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。 rotated 只为向后兼容保留(登录不再轮换,恒为 False): 新 key 是附加 key,旧 key 仍可继续使用,页面不再写“旧 key 已失效”,而是说明多 key 与撤销入口。 """ rotation_note = ( "Your previous API key has been rotated and no longer works.
" if rotated else "Previous keys still work (multi-key). Revoke unused keys " "via the API keys management endpoints.
" if not created else "") return ( "" "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." "
" "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)))) def _oauth_bind_success_page(self, provider, login): """绑定成功页:只显示 provider + 外部 login + 成功提示。 绝不显示任何 key,也不显示本站 user_id:回调页的 completer 未必是绑定发起人,key 与内部 id 一旦上页就可能落到别人 手里;发起人用自己的 key 调 GET /oauth/{provider}/bindings 核对绑定。 """ return ( "" "provider: %s
" "login: %s
" "Your external identity is now bound to your account.
" "You can verify it from your bindings list.
" "" % (html.escape(str(provider)), html.escape(str(login)))) 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, is_bind = \ self._oauth_finish(provider, code, state, client_id, redirect_uri) except BindError as e: self._send_html(409, self._oauth_error_page( e.reason or "bind_conflict", str(e))) return except OAuthError as e: self._send_html(400, self._oauth_error_page( e.reason or "oauth_failed", str(e))) return if is_bind: self._send_html(200, self._oauth_bind_success_page( provider, identity.get("login"))) return self._send_html(200, self._oauth_success_page( provider, local_id, created, identity.get("login"), api_key_plain, rotated)) # -- OAuth 绑定(已有账号绑外部身份)------------------------------ def _oauth_bind_route(self, path): """绑定相关路径解析:返回 (provider, action) 或 None。 POST /oauth/{provider}/bind/initiate -> (provider, "bind_initiate") GET|DELETE /oauth/{provider}/bindings -> (provider, "bindings") 其它 /oauth/ 路径返回 None,走原来的登录/回调分发。 """ parts = [p for p in path.split("/") if p] if len(parts) == 4 and parts[0] == "oauth" \ and parts[2] == "bind" and parts[3] == "initiate": return parts[1], "bind_initiate" if len(parts) == 3 and parts[0] == "oauth" \ and parts[2] == "bindings": return parts[1], "bindings" return None def _oauth_bind_initiate(self, provider): """绑定起点:须持本站 key(与登录不同,绑定必须知道绑到谁)。 body 须含 client_id + redirect_uri(与登录同款);state 里 带 bind_user_id,回调时靠它走绑定分支。只返回授权 URL, 不直接跳转;跳转由调用方决定。 """ user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return client_id = body.get("client_id") redirect_uri = body.get("redirect_uri") scope = body.get("scope") if not client_id or not redirect_uri: self._send_json(400, {"error": "client_id and redirect_uri required"}) return store = OAuthStateStore(self.server.db_path) try: state = store.create_state( provider, redirect_uri, client_id=client_id, bind_user_id=user_id) url = build_authorization_url( provider, client_id, redirect_uri, scope=scope, state=state) except OAuthError as e: self._send_json(400, {"error": str(e), "reason": e.reason}) return except ValueError as e: self._send_json(400, {"error": str(e)}) return finally: try: store.conn.close() except Exception: pass self._send_json(200, {"provider": provider, "state": state, "authorization_url": url}) def _oauth_bindings_get(self, provider): """列出调用方自己的绑定(只看得到自己的,别人的看不到)。""" user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return with _auth_store(self.server.db_path) as auth: bindings = list_bindings(auth, user_id) self._send_json(200, {"provider": provider, "bindings": bindings}) def _oauth_bindings_delete(self, provider): """解绑调用方自己在该 provider 上的绑定:本来就没有返回 404。 解绑后该身份登录恢复原来的 oauth 账号映射(旧 oauth 账号 若存在,key 仍可用,不会被解绑删掉)。 """ user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return with _auth_store(self.server.db_path) as auth: ok = unbind_external_identity(auth, provider, user_id) if ok: auth.audit(user_id, "", "unbind_external", provider) if not ok: self._send_json(404, {"error": "no binding for provider"}) return self._send_json(200, {"unbound": True, "provider": provider}) # -- GitHub App 一键安装 ------------------------------------------ def _github_app_public_base(self): base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or "" return base.rstrip("/") or None def _github_app_error_page(self, reason, message): """App 流程错误页:只含 reason 与提示,不含任何 secret、 pem、code、state(message 来自流程错误,本身不含密钥, 仍做 HTML 转义)。""" return ( "" "reason: %s
%s
" "" % (html.escape(str(reason or "error")), html.escape(str(message)))) def _github_app_setup_state(self): """签发 setup state:只限全局管理员(见 _is_global_admin)。 P0 安全批前任何持 key 用户都能签发并回调覆盖全局 App 配置;现在非管理员直接 403 并记审计。要求环境 变量 GITHUB_APP_PUBLIC_BASE(公网域名,如 https://api.diante.me):manifest 里的 hook / 回调地址 都由它拼出。返回 {state, setup_url},setup_url 是用户 在浏览器里打开的表单页;state 一次性、1 小时过期, 且绑定签发人。 """ user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return if not self._is_global_admin(user_id): self._audit_admin(user_id, "github_app_setup_denied", "setup state") self._send_json(403, {"error": "forbidden"}) return base = self._github_app_public_base() if not base: self._send_json(400, { "error": "GITHUB_APP_PUBLIC_BASE not configured"}) return body, err = self._read_json() if err: body = {} org = (body or {}).get("org") store = SetupStateStore(self.server.db_path) try: state = store.create_state(issued_by=user_id) finally: try: store.conn.close() except Exception: pass setup_url = base + "/github-app/setup?state=" + state if org: setup_url += "&org=" + quote(str(org)) self._send_json(200, {"state": state, "setup_url": setup_url}) def _github_app_setup_page(self): """建 App 表单页:公开页,但 state 必须是操作方签发的。 无 state 或 state 未签发时不渲染表单(state 是回调的 唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。 表单按 GitHub manifest 文档 POST 到 github.com 的建 App 页(个人账号 /settings/apps/new,组织账号 /organizations/{org}/settings/apps/new),manifest JSON 由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回 本站 /github-app/callback。 """ query = parse_qs(urlparse(self.path).query) state = (query.get("state") or [None])[0] org = (query.get("org") or [None])[0] if not state: self._send_html(400, self._github_app_error_page( "missing_state", "setup state required: ask the operator to issue one")) return base = self._github_app_public_base() if not base: self._send_html(400, self._github_app_error_page( "not_configured", "GITHUB_APP_PUBLIC_BASE not configured")) return manifest = build_app_manifest( base, app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME, app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL, public=os.environ.get("GITHUB_APP_PUBLIC") == "1") manifest_json = json.dumps(manifest).replace("<", "\\u003c") if org: action = ("https://github.com/organizations/%s" "/settings/apps/new?state=%s" % (quote(str(org)), quote(state))) else: action = ("https://github.com/settings/apps/new?state=%s" % quote(state)) perms = ", ".join("%s: %s" % kv for kv in manifest["default_permissions"].items()) events = ", ".join(manifest["default_events"]) page = ( "" "App name: %s
" "Webhook URL: %s
" "Permissions: %s
" "Events: %s
" "Click Submit to create the app on GitHub. GitHub will " "redirect back here to finish setup.
" "" "" "" % (html.escape(manifest["name"]), html.escape(manifest["hook_attributes"]["url"]), html.escape(perms), html.escape(events), html.escape(action), manifest_json)) self._send_html(200, page) def _github_app_callback(self): """建 App 回调:验 setup state -> 换 code -> 存配置。 state 一次性、1 小时过期;换 code 失败或响应缺字段时 报错误页,不存任何东西。成功页只显示 App 身份(slug、 id、主页)与安装链接,绝不显示 webhook_secret 与 pem (两者只在库里,server 进程内用)。 P0 安全批后:回调要求签发人仍在 CODEX_GLOBAL_ADMINS 里,否则 state 不消耗、直接报 invalid_state;覆盖全局 配置时记审计。诚实边界:回调是浏览器无认证重定向, 这里核对的是“签发人仍是管理员”,不是“回调者就是 签发人”(见 SetupStateStore.consume_state 文档)。 """ query = parse_qs(urlparse(self.path).query) code = (query.get("code") or [None])[0] state = (query.get("state") or [None])[0] if not code or not state: self._send_html(400, self._github_app_error_page( "missing_params", "code and state required")) return store = SetupStateStore(self.server.db_path) try: issuer = store.state_issuer(state) ok = store.consume_state( state, allowed_issuers=_global_admin_ids()) finally: try: store.conn.close() except Exception: pass if not ok: self._audit_admin( issuer or "unknown", "github_app_callback_denied", "state issuer not a global admin") self._send_html(400, self._github_app_error_page( "invalid_state", "setup state invalid or expired")) return http_client = getattr(self.server, "github_app_exchange_client", None) try: config = exchange_manifest_code(code, http_client=http_client) except AppError as e: self._send_html(400, self._github_app_error_page( e.reason or "exchange_failed", str(e))) return conn = self._db() try: save_app_config(conn, config) finally: conn.close() self._audit_admin( issuer or "unknown", "github_app_config_replaced", "slug=%s app_id=%s" % (config.get("slug"), config.get("app_id"))) slug = config.get("slug") or "" page = ( "" "App: %s
" "App ID: %s
" "Install it on your repositories:
" "" "After installing, webhooks from the app are verified " "automatically. API actions (push, PR) still use the " "existing PAT until the App-token phase; you may remove " "the manual webhook once the app is installed.
" "" % (html.escape(slug), html.escape(str(config.get("app_id"))), html.escape(slug))) self._send_html(200, page) def _github_app_status(self): """App 配置与安装状态:须持本站 key;绝不返回 secret/pem。""" user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return conn = self._db() try: status = public_app_status(conn) status["installations"] = list_installations(conn) finally: conn.close() self._send_json(200, status)