OAuth real browser login: GET callback, userinfo, API key issuance
Add GET /oauth/{provider}/callback (code+state from query, client_id
and redirect_uri recovered from the state record) alongside the
existing POST callback. Callback now fetches real userinfo
(GitHub api.github.com/user, Google openidconnect userinfo) and
issues a usable local API key on success: new users get the
create_user key, existing users rotate (old key dead, audit
oauth_login + api_key_rotated). Key plaintext appears only once
on the success page/response; failures are 400 HTML/JSON without
secrets, and a missing client_secret fails before any network call.
State remains one-time, 10 minutes, provider/redirect_uri matched.
Tests: 10 new OAuth real-login tests pass; host 27, cloud 249
(4 skipped are the pre-existing real-Postgres tests).
Real end-to-end still needs a registered OAuth App and a public
HTTPS callback entry (server still binds localhost only).
This commit is contained in:
1 parent
fbb00b8b6f
commit
0ed301b010
5 files changed
+637
-69
No files matched your search
+168
-33
@@ -1,23 +1,25 @@
|
||||
"""OAuth 接线脚手架(GitHub / Google,首版)。
|
||||
"""OAuth 接线(GitHub / Google)。
|
||||
|
||||
诚实边界:这只是脚手架,不是已接通的真实登录。
|
||||
- 本模块不向真实 GitHub / Google 发任何请求:默认的 HTTP 路径
|
||||
(``_urllib_post``)只在调用方真的传入真实 code 且配好
|
||||
client_secret 时才会被走到;单测一律用注入的 fake http 客户端。
|
||||
真实浏览器登录已接通: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 由
|
||||
调用方以参数传入,client_secret 只从环境变量读
|
||||
登录请求方传入并随 state 一起入库(回调时从 state 记录取回,
|
||||
这样浏览器重定向只带 code + state 也能完成换 token),
|
||||
client_secret 只从环境变量读
|
||||
(GitHub:``GITHUB_OAUTH_CLIENT_SECRET``,
|
||||
Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不
|
||||
硬编码、绝不出现在授权 URL 里。
|
||||
硬编码、绝不出现在授权 URL 或成功页里。
|
||||
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
|
||||
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
|
||||
API,需另设计加密存储,本模块不提供。
|
||||
- 本模块不创建本站会话:回调成功返回的只是外部身份字典,
|
||||
映射到本站用户、建会话、记审计由 server 层用 ``auth.AuthStore``
|
||||
完成(见 ``handle_oauth_callback`` 文档里的 server 层示例)。
|
||||
附带的 ``map_external_identity_to_local_user`` 只复用 auth.py 已有
|
||||
的 users 表(按确定性用户名查找/创建),不发明新的身份表,
|
||||
也不发明新的审计格式。
|
||||
- 本模块不做已有本站账号的绑定:映射仍是确定性用户名
|
||||
``oauth:<provider>:<login>`` 新建/复用,绑定流程需另加。
|
||||
- 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1,
|
||||
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
|
||||
|
||||
流程(标准 OAuth2 授权码流程的接线点):
|
||||
state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期)
|
||||
@@ -49,12 +51,14 @@ 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",
|
||||
},
|
||||
@@ -67,7 +71,7 @@ class OAuthError(Exception):
|
||||
``reason`` 供调用方区分(例如记日志或返回不同的错误提示),
|
||||
取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` /
|
||||
``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed``
|
||||
/ ``identity_missing``。
|
||||
/ ``identity_missing`` / ``userinfo_failed`` / ``client_id_missing``。
|
||||
"""
|
||||
|
||||
def __init__(self, message, reason=None):
|
||||
@@ -103,26 +107,40 @@ class OAuthStateStore:
|
||||
"state TEXT PRIMARY KEY, "
|
||||
"provider TEXT, "
|
||||
"redirect_uri TEXT, "
|
||||
"client_id TEXT, "
|
||||
"created_at REAL)"
|
||||
)
|
||||
# 老库迁移:脚手架版的表没有 client_id 列,缺了就补上。
|
||||
# client_id 随 state 入库,是真实浏览器流程的关键:provider
|
||||
# 重定向只带回 code + state,回调靠 state 记录取回 client_id
|
||||
# 才能换 token;补列是 no-op(已有的库再跑一次不变)。
|
||||
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")
|
||||
self.conn.commit()
|
||||
|
||||
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS):
|
||||
def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS,
|
||||
client_id=None):
|
||||
"""生成并入库一个随机 state,返回 state 字符串。
|
||||
|
||||
``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定,
|
||||
本函数不把过期时间单独存一列:判定统一用
|
||||
``created_at + ttl_seconds``(validate 时传入的 ttl 须与
|
||||
创建时一致,默认两边都是 600 秒)。
|
||||
``client_id`` 随 state 一起入库:真实浏览器回调只带
|
||||
code + state,回调方靠它取回登录时传入的 client_id。
|
||||
"""
|
||||
_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()),
|
||||
"INSERT INTO oauth_states(state, provider, redirect_uri,"
|
||||
" client_id, created_at)"
|
||||
" VALUES(?, ?, ?, ?, ?)",
|
||||
(state, provider, redirect_uri, client_id, time.time()),
|
||||
)
|
||||
self.conn.commit()
|
||||
return state
|
||||
@@ -148,13 +166,13 @@ class OAuthStateStore:
|
||||
if now is None:
|
||||
now = time.time()
|
||||
row = self.conn.execute(
|
||||
"SELECT state, provider, redirect_uri, created_at"
|
||||
"SELECT state, provider, redirect_uri, client_id, 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
|
||||
stored_state, stored_provider, stored_redirect_uri, stored_client_id, created_at = row
|
||||
if stored_provider != provider:
|
||||
return None
|
||||
if redirect_uri is not None and stored_redirect_uri != redirect_uri:
|
||||
@@ -171,6 +189,7 @@ class OAuthStateStore:
|
||||
"state": stored_state,
|
||||
"provider": stored_provider,
|
||||
"redirect_uri": stored_redirect_uri,
|
||||
"client_id": stored_client_id,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
@@ -217,8 +236,10 @@ def start_oauth_login(state_store, provider, client_id, redirect_uri,
|
||||
|
||||
返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL,
|
||||
provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。
|
||||
client_id 随 state 一起入库,回调时从 state 记录取回。
|
||||
"""
|
||||
state = state_store.create_state(provider, redirect_uri)
|
||||
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
|
||||
@@ -310,15 +331,72 @@ def exchange_code_for_token(provider, code, client_id, redirect_uri,
|
||||
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);
|
||||
两边都没有可用身份时返回 None。真实 GitHub 的 token 响应不带
|
||||
身份,生产调用必须传 userinfo_client(例如调
|
||||
https://api.github.com/user 并取 login/id);真实 Google 的
|
||||
id_token 解析同样走 userinfo_client 这一注入点,本模块不解析 JWT。
|
||||
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")
|
||||
@@ -335,15 +413,24 @@ def _identity_from_token_or_userinfo(provider, token_result, userinfo_client):
|
||||
return None
|
||||
try:
|
||||
info = userinfo_client(token_result["access_token"])
|
||||
except Exception:
|
||||
return None
|
||||
except OAuthError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise OAuthError(
|
||||
"userinfo request failed: %s" % (exc,),
|
||||
reason="userinfo_failed",
|
||||
) from exc
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
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:
|
||||
return 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,
|
||||
@@ -368,7 +455,12 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
|
||||
- state 与 provider / redirect_uri 不匹配 ->
|
||||
reason=state_mismatch;
|
||||
- 换 token 失败 -> reason=token_exchange_failed;
|
||||
- 拿不到外部身份 -> reason=identity_missing。
|
||||
- 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 完全兼容,不发明新格式):
|
||||
@@ -392,17 +484,27 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
|
||||
if now is None:
|
||||
now = time.time()
|
||||
peek = state_store.conn.execute(
|
||||
"SELECT provider, redirect_uri, created_at"
|
||||
"SELECT provider, redirect_uri, client_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_created_at = peek
|
||||
peek_provider, peek_redirect_uri, peek_client_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,))
|
||||
@@ -460,3 +562,36 @@ def map_external_identity_to_local_user(auth_store, provider, identity):
|
||||
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`` 创建,返回创建时生成的
|
||||
key,``rotated`` 为 False;
|
||||
- 已存在用户:经 ``AuthStore.rotate_api_key`` 轮换,返回新 key,
|
||||
旧 key 即失效,``rotated`` 为 True;
|
||||
调用方须把明文 key 只在回调成功页/响应里展示一次,之后任何
|
||||
接口都不再返回它;审计由 server 层记(oauth_login 恒记,
|
||||
轮换时另记 api_key_rotated)。
|
||||
"""
|
||||
_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
|
||||
api_key_plain = auth_store.rotate_api_key(row[0])
|
||||
return row[0], False, api_key_plain, True
|
||||
Reference in new issue
Block a user