Files
codex-app-server/cloud/oauth.py
T

462 lines
20 KiB
Python
Raw 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,首版)。
诚实边界:这只是脚手架,不是已接通的真实登录。
- 本模块不向真实 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