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

+244
View File
@@ -0,0 +1,244 @@
"""OAuth 真实浏览器登录的 HTTP 端点测试。
不联网、不向真实 GitHub/Google 发请求:server 的注入点
(server.oauth_http_client / server.oauth_userinfo_client)挂 fake;
真实 userinfo 端点本身只由 cloud.oauth.default_userinfo_client
拼 URL,不在这里调真实网络。测试里出现的 token、key 一律是
fake 占位,绝无真实 client_secret、真实 token、真实 GitHub 账号。
"""
import http.client
import os
import re
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
from cloud.server import create_server
def _fake_token_client(result=None, error=None):
def fake(token_url, payload):
if error is not None:
return {"error": error}
return dict(result if result is not None else
{"access_token": "fake-token-not-real"})
return fake
def _fake_userinfo(result=None, exc=None):
def fake(access_token):
if exc is not None:
raise exc
return dict(result if result is not None else
{"id": 4242, "login": "gh-user",
"email": "gh-user@example.invalid"})
return fake
class OAuthRealLoginTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
# 默认挂 fake:token 响应故意不带身份字段,逼走 userinfo 路径。
self.server.oauth_http_client = _fake_token_client()
self.server.oauth_userinfo_client = _fake_userinfo()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
headers = {}
payload = None
if body is not None:
import json
payload = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
data = resp.read().decode("utf-8", "replace")
finally:
conn.close()
return resp.status, data
def _login_state(self):
status, _ = self._req(
"GET",
"/oauth/github/login?client_id=test-client"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 200)
store = OAuthStateStore(self.db_path)
try:
row = store.conn.execute(
"SELECT state FROM oauth_states ORDER BY created_at DESC"
" LIMIT 1").fetchone()
finally:
store.conn.close()
return row[0]
def _api_key_in_page(self, page):
match = re.search(r"<code>([^<]+)</code>", page)
self.assertIsNotNone(match, page)
return match.group(1)
# -- GET 回调成功:发 key、key 可用、轮换旧 key 失效 -------------
def test_get_callback_success_issues_key_and_rotation(self):
state = self._login_state()
status, page = self._req(
"GET", "/oauth/github/callback?code=fake-code&state=" + state)
self.assertEqual(status, 200, page)
self.assertIn("gh-user", page)
self.assertIn("new user: yes", page)
self.assertNotIn("fake-token-not-real", page)
self.assertNotIn("client_secret", page)
key1 = self._api_key_in_page(page)
user_id = self.auth.authenticate(key1)
self.assertIsNotNone(user_id)
# 同一身份第二次登录:新 key、旧 key 失效、审计有轮换记录。
state2 = self._login_state()
status, page2 = self._req(
"GET", "/oauth/github/callback?code=fake-code&state=" + state2)
self.assertEqual(status, 200, page2)
self.assertIn("new user: no", page2)
self.assertIn("rotated", page2)
key2 = self._api_key_in_page(page2)
self.assertNotEqual(key1, key2)
self.assertIsNone(self.auth.authenticate(key1))
self.assertEqual(self.auth.authenticate(key2), user_id)
actions = [row["action"] for row in self.auth.list_audit("", limit=50)]
self.assertIn("oauth_login", actions)
self.assertIn("api_key_rotated", actions)
def test_post_callback_success_returns_key_once(self):
state = self._login_state()
import json
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": state,
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
parsed = json.loads(body)
self.assertEqual(status, 200, body)
self.assertIn("api_key", parsed)
self.assertFalse(parsed["api_key_rotated"])
self.assertEqual(self.auth.authenticate(parsed["api_key"]),
parsed["user_id"])
# -- GET 回调失败路径 ----------------------------------------------
def test_get_callback_bad_state_400_no_user(self):
status, page = self._req(
"GET", "/oauth/github/callback?code=x&state=no-such-state")
self.assertEqual(status, 400)
self.assertNotIn("fake-token-not-real", page)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_expired_state_400(self):
state = self._login_state()
store = OAuthStateStore(self.db_path)
try:
store.conn.execute(
"UPDATE oauth_states SET created_at = ? WHERE state = ?",
(time.time() - 601, state))
store.conn.commit()
finally:
store.conn.close()
status, _ = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
self.assertEqual(status, 400)
def test_get_callback_state_provider_mismatch_400(self):
state = self._login_state()
status, _ = self._req(
"GET", "/oauth/google/callback?code=x&state=" + state)
self.assertEqual(status, 400)
def test_get_callback_token_exchange_failure_400_no_user(self):
self.server.oauth_http_client = _fake_token_client(
error="bad_verification_code")
state = self._login_state()
status, _ = self._req(
"GET", "/oauth/github/callback?code=bad&state=" + state)
self.assertEqual(status, 400)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_userinfo_failure_400_no_user(self):
self.server.oauth_userinfo_client = _fake_userinfo(exc=RuntimeError("down"))
state = self._login_state()
status, page = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
self.assertEqual(status, 400, page)
self.assertIn("userinfo", page)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_userinfo_empty_400_no_user(self):
self.server.oauth_userinfo_client = _fake_userinfo(result={})
state = self._login_state()
status, _ = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
self.assertEqual(status, 400)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_secret_missing_400_before_network(self):
# 真实路径(不注入 fake):无 secret 时换 token 前就拒绝,
# 不会走到真实网络;fake userinfo 在此路径下不被使用。
self.server.oauth_http_client = None
self.server.oauth_userinfo_client = None
old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
try:
state = self._login_state()
status, page = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
finally:
if old is not None:
os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old
self.assertEqual(status, 400, page)
self.assertIn("client_secret", page)
# -- AuthStore 轮换本身 ---------------------------------------------
def test_rotate_api_key_old_key_dead(self):
user_id, old_key = self.auth.create_user("someone")
new_key = self.auth.rotate_api_key(user_id)
self.assertNotEqual(old_key, new_key)
self.assertIsNone(self.auth.authenticate(old_key))
self.assertEqual(self.auth.authenticate(new_key), user_id)
with self.assertRaises(ValueError):
self.auth.rotate_api_key("no-such-user")
if __name__ == "__main__":
unittest.main()