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:
host committed 2026-10-26 03:35:40 +08:00
1 parent fbb00b8b6f
commit 0ed301b010
5 files changed
+637 -69

No files matched your search

+146 -17
View File
@@ -37,6 +37,7 @@ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突
"""
import argparse
import html
import json
import os
import sqlite3
@@ -58,8 +59,9 @@ from cloud.memory import MemoryStore
from cloud.oauth import (
OAuthError,
OAuthStateStore,
default_userinfo_client,
handle_oauth_callback,
map_external_identity_to_local_user,
map_external_identity_to_local_user_with_key,
start_oauth_login,
)
from cloud.push import (
@@ -280,6 +282,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _send_html(self, status, html_body):
body = html_body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self):
try:
length = int(self.headers.get("Content-Length") or 0)
@@ -341,6 +351,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
# 认证,不走 X-API-Key;不要把它当成无认证后门。
if method == "GET" and path.endswith("/login"):
self._oauth_login(path)
elif method == "GET" and path.endswith("/callback"):
# 真实浏览器流程:provider 重定向到这里,query 带
# code + state(不带 client_id,靠 state 记录取回)。
self._oauth_callback_get(path)
elif method == "POST" and path.endswith("/callback"):
self._oauth_callback(path)
else:
@@ -1209,6 +1223,51 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {"provider": provider, "state": state,
"authorization_url": url})
def _oauth_clients(self, provider):
"""回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。
server 属性 oauth_http_client / oauth_userinfo_client 由
create_server 置为 None(生产)或由测试注入 fake;http_client
已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake
token 自带身份字段时不需要它,走真实端点反而会用假 token
撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。
"""
http_client = getattr(self.server, "oauth_http_client", None)
userinfo_client = getattr(self.server, "oauth_userinfo_client", None)
if userinfo_client is None and http_client is None:
userinfo_client = default_userinfo_client(provider)
return http_client, userinfo_client
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 发本站 key。
成功返回 (local_id, created, api_key_plain, rotated, identity);
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
access_token、client_secret、旧 API key 写进任何响应)。
client_id / redirect_uri 为 None 时由 handle_oauth_callback
从 state 记录取回(真实浏览器回调只带 code + state)。
"""
store = OAuthStateStore(self.server.db_path)
try:
http_client, userinfo_client = self._oauth_clients(provider)
identity = handle_oauth_callback(
store, provider, code, state, client_id, redirect_uri,
http_client=http_client, userinfo_client=userinfo_client)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
local_id, created, api_key_plain, rotated = \
map_external_identity_to_local_user_with_key(
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
if rotated:
auth.audit(local_id, "", "api_key_rotated", provider)
return local_id, created, api_key_plain, rotated, identity
def _oauth_callback(self, path):
provider = self._oauth_provider(path)
if provider is None:
@@ -1222,30 +1281,96 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
state = body.get("state")
client_id = body.get("client_id")
redirect_uri = body.get("redirect_uri")
if not code or not state or not client_id or not redirect_uri:
self._send_json(400, {"error": "code, state, client_id and redirect_uri required"})
if not code or not state:
self._send_json(400, {"error": "code and state required"})
return
store = OAuthStateStore(self.server.db_path)
try:
identity = handle_oauth_callback(
store, provider, code, state, client_id, redirect_uri)
local_id, created, api_key_plain, rotated, identity = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
local_id, created = map_external_identity_to_local_user(
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
# api_key 明文只在这里出现一次:调用方须立即保存,
# 之后任何接口都不再返回它;轮换时旧 key 已失效。
self._send_json(200, {"user_id": local_id, "created": created,
"provider": provider,
"login": identity.get("login"),
"email": identity.get("email")})
"email": identity.get("email"),
"api_key": api_key_plain,
"api_key_rotated": rotated})
def _oauth_error_page(self, reason, message):
"""GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、
token 或旧 API key(message 来自 OAuthError,本身不含密钥,
仍做 HTML 转义)。"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login failed</title></head><body>"
"<h1>OAuth login failed</h1>"
"<p>reason: %s</p><p>%s</p>"
"<p>Please restart login from the login page.</p>"
"</body></html>"
% (html.escape(str(reason or "error")),
html.escape(str(message))))
def _oauth_success_page(self, provider, local_id, created, login,
api_key_plain, rotated):
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
一次;绝不显示 access_token、client_secret、旧 API key。"""
rotation_note = (
"<p>Your previous API key has been rotated and no longer works.</p>"
if rotated else "")
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login succeeded</title></head><body>"
"<h1>OAuth login succeeded</h1>"
"<p>provider: %s</p>"
"<p>login: %s</p>"
"<p>user_id: %s</p>"
"<p>new user: %s</p>"
"%s"
"<p>Your API key (shown only once, save it now):</p>"
"<p><code>%s</code></p>"
"<p>Please save it immediately. It will not be shown again "
"after you leave or refresh this page."
"%s</p>"
"<p>Use it as the X-API-Key header for API calls.</p>"
"</body></html>"
% (html.escape(str(provider)),
html.escape(str(login)),
html.escape(str(local_id)),
"yes" if created else "no",
rotation_note,
html.escape(str(api_key_plain)),
" Your old key has expired (rotated)." if rotated else ""))
def _oauth_callback_get(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_html(404, self._oauth_error_page(
"not_found", "unknown oauth path"))
return
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
state = (query.get("state") or [None])[0]
client_id = (query.get("client_id") or [None])[0]
redirect_uri = (query.get("redirect_uri") or [None])[0]
if not code or not state:
self._send_html(400, self._oauth_error_page(
"missing_params", "code and state required"))
return
try:
local_id, created, api_key_plain, rotated, identity = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except OAuthError as e:
self._send_html(400, self._oauth_error_page(
e.reason or "oauth_failed", str(e)))
return
self._send_html(200, self._oauth_success_page(
provider, local_id, created, identity.get("login"),
api_key_plain, rotated))
# -- 推送订阅与分发 ---------------------------------------------------
def _push_create_endpoint(self, user_id, project_id):
@@ -1363,6 +1488,10 @@ def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
server = ThreadingHTTPServer((host, port), CloudControlPlaneHandler)
server.db_path = db_path
server.daemon_threads = True
# OAuth 回调注入点:生产为 None(走真实换 token + 真实 userinfo),
# 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。
server.oauth_http_client = None
server.oauth_userinfo_client = None
return server