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
+146
-17
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user