- CloudControlPlaneHandler mixes in OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin; shared helpers move to cloud/handler_support.py with server.py re-exports (old import paths keep working, incl. _validate_env_branch for tests). - Method bodies moved verbatim; routers, base plumbing, task/lifecycle, webhooks stay in server.py. No >100-line method body split. - test_handler_split_conformance.py (6 tests) pins resolution, layer bans and method accounting (238 total, 144 + 94, no duplicates). - test_p1_security_server.py: monkeypatch targets follow the defining module (handler_project MAX_PROJECTS_PER_USER, handler_identity relay helpers); assertions unchanged. (cherry picked from commit 92165a2b097126470e4827327d96d592a253adf1)
635 lines
28 KiB
Python
635 lines
28 KiB
Python
"""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 (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>OAuth login failed</title></head><body>"
|
||
"<h1>OAuth login failed</h1>"
|
||
"<p>reason: %s</p><p>%s</p>"
|
||
"<p>Please restart login from the login page.</p>"
|
||
"</body></html>"
|
||
% (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 = (
|
||
"<p>Your previous API key has been rotated and no longer works.</p>"
|
||
if rotated else
|
||
"<p>Previous keys still work (multi-key). Revoke unused keys "
|
||
"via the API keys management endpoints.</p>"
|
||
if not created else "")
|
||
return (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>OAuth login succeeded</title></head><body>"
|
||
"<h1>OAuth login succeeded</h1>"
|
||
"<p>provider: %s</p>"
|
||
"<p>login: %s</p>"
|
||
"<p>user_id: %s</p>"
|
||
"<p>new user: %s</p>"
|
||
"%s"
|
||
"<p>Your API key (shown only once, save it now):</p>"
|
||
"<p><code>%s</code></p>"
|
||
"<p>Please save it immediately. It will not be shown again "
|
||
"after you leave or refresh this page."
|
||
"</p>"
|
||
"<p>Use it as the X-API-Key header for API calls.</p>"
|
||
"</body></html>"
|
||
% (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 (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>OAuth binding succeeded</title></head><body>"
|
||
"<h1>OAuth binding succeeded</h1>"
|
||
"<p>provider: %s</p>"
|
||
"<p>login: %s</p>"
|
||
"<p>Your external identity is now bound to your account.</p>"
|
||
"<p>You can verify it from your bindings list.</p>"
|
||
"</body></html>"
|
||
% (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 (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>GitHub App setup failed</title></head><body>"
|
||
"<h1>GitHub App setup failed</h1>"
|
||
"<p>reason: %s</p><p>%s</p>"
|
||
"</body></html>"
|
||
% (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 = (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>Create GitHub App</title></head><body>"
|
||
"<h1>Create GitHub App</h1>"
|
||
"<p>App name: %s</p>"
|
||
"<p>Webhook URL: %s</p>"
|
||
"<p>Permissions: %s</p>"
|
||
"<p>Events: %s</p>"
|
||
"<p>Click Submit to create the app on GitHub. GitHub will "
|
||
"redirect back here to finish setup.</p>"
|
||
"<form action=\"%s\" method=\"post\">"
|
||
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
|
||
"<input type=\"submit\" value=\"Create GitHub App\">"
|
||
"</form>"
|
||
"<script>document.getElementById(\"manifest\").value = "
|
||
"JSON.stringify(%s);</script>"
|
||
"</body></html>"
|
||
% (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 = (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>GitHub App created</title></head><body>"
|
||
"<h1>GitHub App created</h1>"
|
||
"<p>App: %s</p>"
|
||
"<p>App ID: %s</p>"
|
||
"<p>Install it on your repositories:</p>"
|
||
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
|
||
"Install this GitHub App</a></p>"
|
||
"<p>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.</p>"
|
||
"</body></html>"
|
||
% (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)
|