Files
codex-app-server/cloud/oauth.py
host f39ed3654e ci3: lint/mypy 第二阶段清理 + 覆盖率 76% -> 78%(门槛不动)
基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上),
基线 origin/main aebc6238;本轨不改 #251 已定语义、不动
workflow 门槛、不删检查、不加 skip。

lint(ruff E,F@120):109 -> 10
- F401 43 -> 0:删未用导入;try/except 双绑定的名字出现
  “打地鼠”(删 except 分支后 try 分支又被标出),两分支
  均已核对正文无引用后清掉;MCP 三模块的 Error 名不删,
  改走 importlib 属性绑定(见下),两路径绑定都保留。
- E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行,
  tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号);
  一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。
- E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server
  3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值
  完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host
  5 项断开 git commit 参数表。
- E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app
  _base64 移到顶部,之前无引用、无重名);剩余 10 项全是
  tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件
  沿用同一既定模式),移上去会破坏单文件直跑,有意保留。
- F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、
  tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖;
  host dispatch_from_branch 新增真实 git 仓库回归测试。
- F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get
  逐行相同,删本地定义,全部调用点解析到同一 import;
  新增 test_server_row_get(同一对象 + 三路行为)。

mypy(生产范围):24 -> 0,零新增 type: ignore
- no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import
  改 importlib.import_module + 属性绑定(cloud 优先、flat
  copy 回落、内层 ImportError 同样回落,语义与原来一致;
  flat-copy 子进程测试证实回落后 __module__ 为顶层名);
  server _row_get 随 F811 消除。
- arg-type 10 -> 0:harness2_tasks shared 改 TypedDict
  逐键精确类型;CodexProbeAdapter 补 no-op validate_tree
  (_adapter_validate 本就跳过缺失方法,行为一致)。
- var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、
  verify-multiuser RESULTS 加精确注解。
- assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明
  Any 再 try import(草稿先验 mypy 通过)。

bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改
(属第 3 项白名单轨范围),只做 triage:
- 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell
  语义(新增 test_host_run_verify 断言管道与 && 真实生效)。
- 1497 notify:模板来自算子配置,三处插值全部 shlex.quote,
  证据为既有 test_notify_failure_quotes_shell_metacharacters
  (; touch 与 $(touch ) 不执行)。
- 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。
- 无新增 High;pip-audit 0 漏洞。

覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87%
(22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%)
- client.py 0% -> 90%(pipe 假子进程 + main 分支)
- run_parallel.py 0% -> 97%、migrate 包装 0% -> 95%
- playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、
  websearch_mcp 51% -> 86%、deploy.py 56% -> 77%
- verify-multiuser 0% -> 27%(仅 check/req helper;main()
  是主机侧真实多用户实弹验证,按门禁不建生产任务、
  不以 mock 冒充,仍为缺口)
- host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行
  的计量口径变化,非代码膨胀)
- 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径;
  缺口主力仍是 server.py / worker.py / host.py 大文件,
  下一阶段须针对性深测,非本轨快赢可及。

验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests
1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0;
bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删,
提交后工作树干净。
2026-11-03 02:48:54 +00:00

750 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OAuth 接线(GitHub / Google)。
真实浏览器登录已接通: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 由
登录请求方传入并随 state 一起入库(回调时从 state 记录取回,
这样浏览器重定向只带 code + state 也能完成换 token),
client_secret 只从环境变量读
(GitHub:``GITHUB_OAUTH_CLIENT_SECRET``,
Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不
硬编码、绝不出现在授权 URL 或成功页里。
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
API,需另设计加密存储,本模块不提供。
- 已有本站账号的绑定(本模块提供,server 层接线):绑定表
``external_identities`` + ``bind_external_identity`` 等函数。
绑定流程:已登录用户(持本站 key)调
``POST /oauth/{provider}/bind/initiate`` 拿授权 URL,state 里带
``bind_user_id``;回调时 state 带 bind 标记即走绑定分支,不走
登录映射。登录解析顺序:先查绑定表(绑过的身份落到被绑账号,
并按 OAuth 登录规矩轮换 key),没绑过才走确定性用户名
``oauth:<provider>:<login>`` 新建/复用。绑定成功页绝不显示任何
key;解绑后恢复原来的 oauth 账号登录。
- 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1,
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
流程(标准 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
from cloud.db import connect as _db_connect
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",
"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",
},
}
class OAuthError(Exception):
"""OAuth 流程失败:state 无效/过期、换 token 失败、身份取不到等。
``reason`` 供调用方区分(例如记日志或返回不同的错误提示),
取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` /
``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed``
/ ``identity_missing`` / ``userinfo_failed`` / ``client_id_missing``。
"""
def __init__(self, message, reason=None):
super().__init__(message)
self.reason = reason
class BindError(Exception):
"""绑定冲突:调用方按 409 呈现,绝不静默覆盖别人的绑定。
reason 取值:``identity_bound_to_other``(该外部身份已被另
一个本站账号绑定)/ ``user_already_bound``(该本站账号已绑定
了同一 provider 的另一个外部身份,须先解绑)。
"""
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 秒)、client_id、bind_user_id(绑定流程才有,
登录流程为空)。与 AuthStore 的 users / project_members /
audit_log 表名不冲突,可共用同一个 db 文件。
"""
def __init__(self, db_path):
self.db_path = str(db_path)
self.conn = _db_connect(self.db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS oauth_states("
"state TEXT PRIMARY KEY, "
"provider TEXT, "
"redirect_uri TEXT, "
"client_id TEXT, "
"bind_user_id TEXT, "
"created_at REAL)"
)
# 老库迁移:脚手架版的表没有 client_id / bind_user_id 列,
# 缺了就补上。client_id 随 state 入库,是真实浏览器流程的
# 关键;bind_user_id 随 state 入库,是绑定流程的关键
# (回调时靠它知道这次回调是绑定到哪个本站账号)。
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")
if "bind_user_id" not in cols:
self.conn.execute(
"ALTER TABLE oauth_states ADD COLUMN bind_user_id TEXT")
self.conn.commit()
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS,
client_id=None, bind_user_id=None):
"""生成并入库一个随机 state,返回 state 字符串。
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
本函数不把过期时间单独存一列:判定统一用
``created_at + ttl_seconds``(validate 时传入的 ttl 须与
创建时一致,默认两边都是 600 秒)。
``client_id`` 随 state 一起入库:真实浏览器回调只带
code + state,回调方靠它取回登录时传入的 client_id。
``bind_user_id`` 非空时这个 state 是绑定流程的:回调方靠
它知道绑定到哪个本站账号,绝不走登录映射。
"""
_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,"
" client_id, bind_user_id, created_at)"
" VALUES(?, ?, ?, ?, ?, ?)",
(state, provider, redirect_uri, client_id, bind_user_id,
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, client_id, bind_user_id,"
" created_at"
" FROM oauth_states WHERE state = ?",
(state,),
).fetchone()
if row is None:
return None
stored_state, stored_provider, stored_redirect_uri, stored_client_id, stored_bind_user_id, 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,
"client_id": stored_client_id,
"bind_user_id": stored_bind_user_id,
"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`` 校验。
client_id 随 state 一起入库,回调时从 state 记录取回。
"""
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
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 _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)。
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")
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 OAuthError:
raise
except Exception as exc:
raise OAuthError(
"userinfo request failed: %s" % (exc,),
reason="userinfo_failed",
) from exc
if not isinstance(info, dict):
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:
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,
"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",
"bind_user_id"}``
(缺失的字段为 None;external_id 至少与 login/email 其一存在,
否则抛 OAuthError;``bind_user_id`` 非空表示这次回调是绑定
流程,调用方须走绑定分支、绝不走登录映射)。
失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录:
- state 不存在 -> reason=state_not_found;
- state 过期 -> reason=state_expired(过期行在拒绝时被删除);
- state 与 provider / redirect_uri 不匹配 ->
reason=state_mismatch;
- 换 token 失败 -> reason=token_exchange_failed;
- 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 完全兼容,不发明新格式):
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, client_id, bind_user_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_client_id, peek_bind_user_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,))
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")
identity["bind_user_id"] = peek_bind_user_id
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
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`` 创建,返回创建时生成的
primary key,``rotated`` 为 False;
- 已存在用户:经 ``AuthStore.create_api_key`` 签发一个附加 key
(label 为 ``oauth:<provider>``),旧 key(primary 与之前
的附加 key)全部继续有效,``rotated`` 恒为 False;
调用方须把明文 key 只在回调成功页/响应里展示一次,之后任何
接口都不再返回它;审计由 server 层记(oauth_login 恒记,
签发附加 key 时另记 api_key_issued)。``rotated`` 字段只为
向后兼容保留,恒为 False——登录不再轮换任何 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 None:
# 新用户:直接返回创建时生成的 key,不再多轮换一次。
user_id, api_key_plain = auth_store.create_user(username)
return user_id, True, api_key_plain, False
_key_id, api_key_plain = auth_store.create_api_key(
row[0], "oauth:%s" % provider)
return row[0], False, api_key_plain, False
def _binding_key(identity):
"""绑定键:external_id 优先(GitHub id / Google sub),没有才
用 login;登录解析查绑定时用同一派生,键必须两边一致。"""
return identity.get("external_id") or identity.get("login")
def ensure_external_identities_table(conn):
"""建绑定表(幂等):provider + 外部键 + 外部 login + 本站
user_id + 创建时间;两个唯一约束防一对多乱绑。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS external_identities("
"provider TEXT, "
"external_id TEXT, "
"external_login TEXT, "
"user_id TEXT, "
"created_at REAL, "
"UNIQUE(provider, external_id), "
"UNIQUE(provider, user_id))"
)
conn.commit()
def bind_external_identity(auth_store, provider, identity, user_id):
"""把外部身份绑到已有本站账号。
冲突一律抛 BindError(调用方按 409 呈现),绝不静默覆盖:
- 该外部身份已被另一个本站账号绑定 ->
reason=identity_bound_to_other;
- 该本站账号已绑定了同一 provider 的另一个外部身份 ->
reason=user_already_bound(须先解绑)。
同一用户 + 同一身份重复绑是幂等的:返回 already=True,不报错、
不改行。成功返回绑定 dict(含 already=False)。
"""
_provider_config(provider)
key = _binding_key(identity)
if key is None:
raise BindError("identity has no external_id/login to bind",
reason="identity_missing")
ensure_external_identities_table(auth_store.conn)
row = auth_store.conn.execute(
"SELECT user_id FROM external_identities"
" WHERE provider = ? AND external_id = ?",
(provider, key),
).fetchone()
if row is not None:
if row[0] == user_id:
return {"provider": provider, "external_id": key,
"external_login": identity.get("login"),
"user_id": user_id, "already": True}
raise BindError(
"external identity already bound to another user",
reason="identity_bound_to_other")
row = auth_store.conn.execute(
"SELECT external_id FROM external_identities"
" WHERE provider = ? AND user_id = ?",
(provider, user_id),
).fetchone()
if row is not None:
raise BindError(
"user already bound to another external identity",
reason="user_already_bound")
auth_store.conn.execute(
"INSERT INTO external_identities(provider, external_id,"
" external_login, user_id, created_at)"
" VALUES(?, ?, ?, ?, ?)",
(provider, key, identity.get("login"), user_id, time.time()),
)
auth_store.conn.commit()
return {"provider": provider, "external_id": key,
"external_login": identity.get("login"),
"user_id": user_id, "already": False}
def find_bound_user(auth_store, provider, identity):
"""登录解析用:该外部身份绑到了哪个本站账号,没有返回 None。
键派生与绑定时同一规则(external_id 优先,否则 login)。
"""
_provider_config(provider)
key = _binding_key(identity)
if key is None:
return None
ensure_external_identities_table(auth_store.conn)
row = auth_store.conn.execute(
"SELECT user_id FROM external_identities"
" WHERE provider = ? AND external_id = ?",
(provider, key),
).fetchone()
return row[0] if row is not None else None
def list_bindings(auth_store, user_id):
"""列出某本站账号的全部绑定(按 provider 排序)。"""
ensure_external_identities_table(auth_store.conn)
rows = auth_store.conn.execute(
"SELECT provider, external_id, external_login, created_at"
" FROM external_identities WHERE user_id = ? ORDER BY provider",
(user_id,),
).fetchall()
return [{"provider": r[0], "external_id": r[1],
"external_login": r[2], "created_at": r[3]} for r in rows]
def unbind_external_identity(auth_store, provider, user_id):
"""解绑某本站账号在某 provider 上的绑定:删掉返回 True,
本来就没有返回 False。解绑后该身份登录恢复原来的 oauth
账号映射(旧 oauth 账号若存在,key 仍可用)。"""
_provider_config(provider)
ensure_external_identities_table(auth_store.conn)
cur = auth_store.conn.execute(
"DELETE FROM external_identities"
" WHERE provider = ? AND user_id = ?",
(provider, user_id),
)
auth_store.conn.commit()
return cur.rowcount > 0