cloud: fill gaps (memory/KB/cost/models endpoints, WAL concurrency, OAuth scaffold, push subscriptions)

This commit is contained in:
host committed 2026-10-26 01:32:57 +08:00
1 parent b74d99e959
commit 076ba01c74
11 files changed
+2831 -28

No files matched your search

+461
View File
@@ -0,0 +1,461 @@
"""OAuth 接线脚手架(GitHub / Google,首版)。
诚实边界:这只是脚手架,不是已接通的真实登录。
- 本模块不向真实 GitHub / Google 发任何请求:默认的 HTTP 路径
(``_urllib_post``)只在调用方真的传入真实 code 且配好
client_secret 时才会被走到;单测一律用注入的 fake http 客户端。
- 本模块不硬编码任何真实 client_id / client_secret:client_id 由
调用方以参数传入,client_secret 只从环境变量读
(GitHub:``GITHUB_OAUTH_CLIENT_SECRET``,
Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不
硬编码、绝不出现在授权 URL 里。
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
API,需另设计加密存储,本模块不提供。
- 本模块不创建本站会话:回调成功返回的只是外部身份字典,
映射到本站用户、建会话、记审计由 server 层用 ``auth.AuthStore``
完成(见 ``handle_oauth_callback`` 文档里的 server 层示例)。
附带的 ``map_external_identity_to_local_user`` 只复用 auth.py 已有
的 users 表(按确定性用户名查找/创建),不发明新的身份表,
也不发明新的审计格式。
流程(标准 OAuth2 授权码流程的接线点):
state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期)
-> 授权 URL(build_authorization_url / start_oauth_login)
-> 用户在 provider 处授权,provider 回调本站并带回 code + state
-> 回调(handle_oauth_callback):验 state -> 换 token
-> 取外部身份 -> 返回身份字典(server 层再映射 + 记审计)
零第三方依赖,只用 Python 标准库。
"""
import json
import os
import secrets
import sqlite3
import time
import urllib.parse
import urllib.request
from urllib.parse import parse_qs
# state 默认有效期:10 分钟。
STATE_TTL_SECONDS = 600
# 各 provider 的公开端点与默认 scope、client_secret 环境变量名。
# 这些是 OAuth 协议的公开端点地址,不是本站的任何凭证;真实应用
# 的 client_id / client_secret 仍需另行注册并经环境变量供给。
PROVIDERS = {
"github": {
"authorize_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"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",
"default_scope": "openid email profile",
"client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET",
},
}
class OAuthError(Exception):
"""OAuth 流程失败:state 无效/过期、换 token 失败、身份取不到等。
``reason`` 供调用方区分(例如记日志或返回不同的错误提示),
取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` /
``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed``
/ ``identity_missing``。
"""
def __init__(self, message, reason=None):
super().__init__(message)
self.reason = reason
def _provider_config(provider):
"""取 provider 配置,未知 provider 抛 OAuthError(不猜端点)。"""
config = PROVIDERS.get(provider)
if config is None:
raise OAuthError(
"unknown provider: %r (supported: %s)"
% (provider, ", ".join(sorted(PROVIDERS))),
reason="unknown_provider",
)
return config
class OAuthStateStore:
"""基于 SQLite 的 OAuth state 存储。
表 oauth_states:state(主键,随机串)、provider、redirect_uri、
created_at(epoch 秒)。与 AuthStore 的 users / project_members /
audit_log 表名不冲突,可共用同一个 db 文件。
"""
def __init__(self, db_path):
self.db_path = str(db_path)
self.conn = sqlite3.connect(self.db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS oauth_states("
"state TEXT PRIMARY KEY, "
"provider TEXT, "
"redirect_uri TEXT, "
"created_at REAL)"
)
self.conn.commit()
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS):
"""生成并入库一个随机 state,返回 state 字符串。
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
本函数不把过期时间单独存一列:判定统一用
``created_at + ttl_seconds``(validate 时传入的 ttl 须与
创建时一致,默认两边都是 600 秒)。
"""
_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()),
)
self.conn.commit()
return state
def validate_state(self, state, provider, redirect_uri=None,
ttl_seconds=STATE_TTL_SECONDS, now=None):
"""校验 state,成功返回记录 dict,失败返回 None(绝不抛错)。
校验规则(任一不满足即拒绝):
- state 在库里存在,否则 None(不存在);
- 记录的 provider 与传入一致,否则 None(不匹配,且不消耗
该 state,避免一次错 provider 的尝试烧掉合法 state);
- redirect_uri 若传入,须与记录一致,否则 None(不匹配);
- ``now``(默认 time.time())未超过
``created_at + ttl_seconds``,否则删除该行并返回 None(过期)。
成功是“一次性”的:返回前删除该行,同一 state 第二次校验
必然失败,防止重放。``now`` 参数只为单测注入时间,
生产调用传默认即可。
"""
if not state:
return None
if now is None:
now = time.time()
row = self.conn.execute(
"SELECT state, provider, redirect_uri, 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
if stored_provider != provider:
return None
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
return None
if now > created_at + ttl_seconds:
self.conn.execute(
"DELETE FROM oauth_states WHERE state = ?", (state,))
self.conn.commit()
return None
self.conn.execute(
"DELETE FROM oauth_states WHERE state = ?", (state,))
self.conn.commit()
return {
"state": stored_state,
"provider": stored_provider,
"redirect_uri": stored_redirect_uri,
"created_at": created_at,
}
def build_authorization_url(provider, client_id, redirect_uri, scope=None,
state=None):
"""拼授权 URL(纯字符串拼接,不发任何请求、不含任何 secret)。
参数:
- provider:``"github"`` 或 ``"google"``,未知抛 OAuthError;
- client_id:调用方传入的真实应用 id,本模块不硬编码、不校验
其真实性(注册见模块文档“真实 OAuth 仍缺什么”);
- redirect_uri:须与在 provider 处注册的回调地址一致;
- scope:不传则用该 provider 的默认 scope(GitHub ``read:user``,
Google ``openid email profile``);
- state:不传则现场随机生成一个(调用方若要校验,必须自己
用 OAuthStateStore 存下它;推荐直接用 ``start_oauth_login``,
它把生成 + 入库 + 拼 URL 一次做完)。
返回的 URL 只含 client_id / redirect_uri / scope / state(Google
另含 response_type=code),绝不含 client_secret。
"""
config = _provider_config(provider)
if not client_id or not isinstance(client_id, str):
raise ValueError("client_id required")
if not redirect_uri or not isinstance(redirect_uri, str):
raise ValueError("redirect_uri required")
if state is None:
state = secrets.token_urlsafe(32)
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope or config["default_scope"],
"state": state,
}
if provider == "google":
params["response_type"] = "code"
return config["authorize_url"] + "?" + urllib.parse.urlencode(params)
def start_oauth_login(state_store, provider, client_id, redirect_uri,
scope=None):
"""登录起点:生成并入库 state,再拼授权 URL。
返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL,
provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。
"""
state = state_store.create_state(provider, redirect_uri)
url = build_authorization_url(
provider, client_id, redirect_uri, scope=scope, state=state)
return state, url
def _urllib_post(token_url, payload):
"""默认的真实换 token 实现(urllib POST,返回解析后的 dict)。
只在调用方未注入 http_client 且配好 client_secret 时走到;
GitHub 的 token 端点默认回表单编码,本函数先试 JSON、
失败再按表单解析,两种都解析不出则返回空 dict(由调用方
判为换 token 失败)。
"""
data = urllib.parse.urlencode(payload).encode("utf-8")
request = urllib.request.Request(
token_url, data=data, method="POST",
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode("utf-8", "replace")
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except ValueError:
pass
form = parse_qs(raw)
return {key: values[0] for key, values in form.items() if values}
def exchange_code_for_token(provider, code, client_id, redirect_uri,
http_client=None, client_secret=None):
"""用授权码换 token,返回 provider 的 token 响应 dict。
http_client 注入约定(单测与未来真实接线共用同一签名):
``http_client(token_url, payload_dict) -> dict``,其中 payload 含
client_id / code / redirect_uri,Google 另含
grant_type=authorization_code;client_secret 仅在有值时才放进
payload。返回的 dict 须含 ``access_token``,否则视为换 token 失败;
http_client 抛异常、或返回含 ``error`` 的 dict,同样视为失败。
client_secret 供给:参数优先,未传则读该 provider 对应的环境变量
(见 PROVIDERS 的 client_secret_env)。真实路径(http_client
为 None)下仍无 secret 时,在发任何请求前就抛 OAuthError,
绝不以空 secret 去撞真实端点。注入了 http_client 的测试路径
允许 secret 缺省(fake 不需要真实 secret),生产调用不得依赖
这一宽松分支。
"""
config = _provider_config(provider)
if not code or not isinstance(code, str):
raise OAuthError("code required", reason="token_exchange_failed")
if client_secret is None:
client_secret = os.environ.get(config["client_secret_env"])
if http_client is None and not client_secret:
raise OAuthError(
"client_secret not configured: set %s (never hard-code it)"
% config["client_secret_env"],
reason="client_secret_missing",
)
payload = {
"client_id": client_id,
"code": code,
"redirect_uri": redirect_uri,
}
if provider == "google":
payload["grant_type"] = "authorization_code"
if client_secret is not None:
payload["client_secret"] = client_secret
client = http_client if http_client is not None else _urllib_post
try:
result = client(config["token_url"], payload)
except Exception as exc:
raise OAuthError(
"token exchange failed: %s" % (exc,), reason="token_exchange_failed"
) from exc
if not isinstance(result, dict) or result.get("error"):
raise OAuthError(
"token exchange failed: %r" % (result,),
reason="token_exchange_failed",
)
if not result.get("access_token"):
raise OAuthError(
"token exchange returned no access_token",
reason="token_exchange_failed",
)
return result
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。
"""
for source in (token_result,):
external_id = source.get("id") or source.get("sub")
login = source.get("login") or source.get("name")
email = source.get("email")
if external_id is not None or login is not None or email is not None:
return {
"provider": provider,
"external_id": str(external_id) if external_id is not None else None,
"login": login,
"email": email,
}
if userinfo_client is None:
return None
try:
info = userinfo_client(token_result["access_token"])
except Exception:
return None
if not isinstance(info, dict):
return None
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
return {
"provider": provider,
"external_id": str(external_id) if external_id is not None else None,
"login": login,
"email": email,
}
def handle_oauth_callback(state_store, provider, code, state, client_id,
redirect_uri, http_client=None, userinfo_client=None,
client_secret=None, now=None,
ttl_seconds=STATE_TTL_SECONDS):
"""处理 provider 回调:验 state -> 换 token -> 返回外部身份。
成功返回 dict:``{"provider", "external_id", "login", "email"}``
(缺失的字段为 None;external_id 至少与 login/email 其一存在,
否则抛 OAuthError)。
失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录:
- state 不存在 -> reason=state_not_found;
- state 过期 -> reason=state_expired(过期行在拒绝时被删除);
- state 与 provider / redirect_uri 不匹配 ->
reason=state_mismatch;
- 换 token 失败 -> reason=token_exchange_failed;
- 拿不到外部身份 -> reason=identity_missing。
本函数 deliberately 不做以下三件事,全部留给 server 层(签名
与 auth.py 完全兼容,不发明新格式):
1. 映射到本站用户:可用 ``map_external_identity_to_local_user``
(复用 AuthStore 的 users 表)或 server 层自己的映射;
2. 建会话:本站首版会话就是 AuthStore 的 API key,OAuth 登录后
是否发 key、发给谁,由 server 层决定,本模块不发;
3. 记审计:server 层在映射出本站 user_id 后调用
``AuthStore.audit(user_id, project_id, "oauth_login",
"%s:%s" % (provider, external_id))``——与 auth.py 现有
audit(action, detail) 写法一致,project_id 为登录发生的
项目上下文、无项目上下文时 server 层自定(例如空串),
本模块不替 server 层选 project_id。
access_token 在本函数内只用于取身份(经 userinfo_client),
返回前即丢弃,本模块不存储、不返回它。
"""
# 先区分拒绝原因(validate_state 本身只返回记录或 None):
# 不存在 / provider 或 redirect_uri 不匹配 / 过期各有 reason;
# 过期行在此处删除(validate_state 的过期分支同样会删,幂等)。
if now is None:
now = time.time()
peek = state_store.conn.execute(
"SELECT provider, redirect_uri, 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
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 now > peek_created_at + ttl_seconds:
state_store.conn.execute(
"DELETE FROM oauth_states WHERE state = ?", (state,))
state_store.conn.commit()
raise OAuthError("state expired", reason="state_expired")
record = state_store.validate_state(
state, provider, redirect_uri=redirect_uri, now=now,
ttl_seconds=ttl_seconds)
if record is None:
raise OAuthError(
"invalid state", reason="state_not_found")
token_result = exchange_code_for_token(
provider, code, client_id, redirect_uri,
http_client=http_client, client_secret=client_secret)
identity = _identity_from_token_or_userinfo(
provider, token_result, userinfo_client)
if identity is None:
raise OAuthError(
"cannot determine external identity: token response carries"
" no identity fields and no userinfo_client was provided",
reason="identity_missing",
)
if identity["external_id"] is None and identity["login"] is None \
and identity["email"] is None:
raise OAuthError(
"empty external identity", reason="identity_missing")
return identity
def map_external_identity_to_local_user(auth_store, provider, identity):
"""把外部身份映射到本站用户(复用 AuthStore 的 users 表)。
确定性用户名:``oauth:<provider>:<login 或 external_id>``。
已存在则返回 ``(user_id, False)``,不存在则经
``AuthStore.create_user`` 创建并返回 ``(user_id, True)``——
创建时生成的 API key 明文按 auth.py 约定只应返回给调用方一次,
本函数不返回它(调用方如需发 key 给登录用户,须另行决定
发放方式,本模块不代发)。这只是脚手架的默认映射:生产若要
把外部身份绑定到已有本站账号(而非每次新建),需另加绑定表
与绑定流程,本函数不假装已做绑定。
"""
_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 not None:
return row[0], False
user_id, _api_key_plain = auth_store.create_user(username)
return user_id, True