"""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::``。 已存在则返回 ``(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