462 lines
20 KiB
Python
462 lines
20 KiB
Python
"""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
|