多 key:OAuth 登录签发附加 key 不再轮换,keys 管理端点

This commit is contained in:
host committed 2026-10-26 16:27:42 +08:00
1 parent aa5ad41eba
commit 7b85b4a4fd
7 files changed
+544 -37

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
"""API keys 多 key 单测:primary + 附加 key、撤销、revoke-others、
登录签发附加 key(不再轮换)。
测试里出现的 key 一律是测试内生成的假 key,绝无真实 secret。
"""
import hashlib
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import map_external_identity_to_local_user_with_key
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 map_external_identity_to_local_user_with_key
from cloud.server import create_server
class ApiKeysFunctionTestCase(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.user_id, self.primary = self.auth.create_user("user-a")
def tearDown(self):
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def test_primary_listed_as_legacy(self):
keys = self.auth.list_api_keys(self.user_id)
self.assertEqual(len(keys), 1)
self.assertEqual(keys[0]["id"], "legacy")
self.assertEqual(keys[0]["label"], "primary")
self.assertIsNone(keys[0]["hint"])
self.assertIsNone(keys[0]["revoked_at"])
# 列表里绝无明文与哈希。
for key in keys:
self.assertNotIn("api_key", key)
self.assertNotIn("key_hash", key)
def test_additional_key_authenticates_and_revokes(self):
key_id, plain = self.auth.create_api_key(self.user_id, "laptop")
self.assertEqual(self.auth.authenticate(plain), self.user_id)
self.assertEqual(plain[-4:], [
k for k in self.auth.list_api_keys(self.user_id)
if k["id"] == key_id][0]["hint"])
self.assertTrue(self.auth.revoke_api_key(self.user_id, key_id))
self.assertIsNone(self.auth.authenticate(plain))
self.assertEqual(self.auth.authenticate(self.primary), self.user_id)
self.assertFalse(self.auth.revoke_api_key(self.user_id, key_id))
def test_revoke_legacy_keeps_additional(self):
_key_id, plain = self.auth.create_api_key(self.user_id, "ci")
self.assertTrue(self.auth.revoke_api_key(self.user_id, "legacy"))
self.assertIsNone(self.auth.authenticate(self.primary))
self.assertEqual(self.auth.authenticate(plain), self.user_id)
self.assertFalse(self.auth.revoke_api_key(self.user_id, "legacy"))
def test_revoke_others_keeps_only_current(self):
_id1, plain1 = self.auth.create_api_key(self.user_id, "a")
_id2, plain2 = self.auth.create_api_key(self.user_id, "b")
keep = hashlib.sha256(plain2.encode()).hexdigest()
count = self.auth.revoke_other_api_keys(self.user_id, keep)
self.assertEqual(count, 2) # primary + 第一个附加 key
self.assertIsNone(self.auth.authenticate(self.primary))
self.assertIsNone(self.auth.authenticate(plain1))
self.assertEqual(self.auth.authenticate(plain2), self.user_id)
def test_last_used_throttled(self):
key_id, plain = self.auth.create_api_key(self.user_id, "x")
self.assertEqual(self.auth.authenticate(plain), self.user_id)
first = [k for k in self.auth.list_api_keys(self.user_id)
if k["id"] == key_id][0]["last_used_at"]
self.assertIsNotNone(first)
self.assertEqual(self.auth.authenticate(plain), self.user_id)
second = [k for k in self.auth.list_api_keys(self.user_id)
if k["id"] == key_id][0]["last_used_at"]
self.assertEqual(first, second) # 一小时内不重复写库
def test_create_for_unknown_user(self):
with self.assertRaises(ValueError):
self.auth.create_api_key("no-such-user", "x")
def test_oauth_relogin_issues_additional_not_rotation(self):
# 已存在 oauth 用户再次登录:签发附加 key,旧 primary 不死。
user_id, primary = self.auth.create_user("oauth:github:gh-user")
_uid, created, plain, rotated = \
map_external_identity_to_local_user_with_key(
self.auth, "github", {"login": "gh-user",
"external_id": "4242"})
self.assertEqual(_uid, user_id)
self.assertFalse(created)
self.assertFalse(rotated)
self.assertNotEqual(plain, primary)
self.assertEqual(self.auth.authenticate(primary), user_id)
self.assertEqual(self.auth.authenticate(plain), user_id)
class ApiKeysHttpTestCase(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.user_a, self.key_a = self.auth.create_user("user-a")
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()
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, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
headers = {}
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
if key is not None:
headers["X-API-Key"] = key
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode()
try:
parsed = json.loads(raw)
except ValueError:
parsed = raw
return resp.status, parsed
finally:
conn.close()
def test_keys_require_auth(self):
status, _ = self._req("GET", "/users/me/keys")
self.assertEqual(status, 401)
status, _ = self._req("POST", "/users/me/keys", {})
self.assertEqual(status, 401)
def test_create_list_revoke_flow(self):
status, body = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertEqual(len(body["keys"]), 1)
self.assertEqual(body["keys"][0]["id"], "legacy")
# 创建附加 key:明文只出现一次,hint 为后 4 位。
status, body = self._req(
"POST", "/users/me/keys", {"label": "laptop"}, key=self.key_a)
self.assertEqual(status, 201, body)
new_key = body["api_key"]
self.assertEqual(body["hint"], new_key[-4:])
key_id = body["id"]
# 新 key 可用;列表里有两条且无明文。
status, _ = self._req("GET", "/users/me/keys", key=new_key)
self.assertEqual(status, 200)
status, body = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(len(body["keys"]), 2)
self.assertNotIn(new_key, json.dumps(body))
# 撤销附加 key:新 key 401,primary 仍可用;再撤 404。
status, body = self._req(
"DELETE", "/users/me/keys/" + key_id, key=self.key_a)
self.assertEqual(status, 200, body)
status, _ = self._req("GET", "/users/me/keys", key=new_key)
self.assertEqual(status, 401)
status, _ = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 200)
status, _ = self._req(
"DELETE", "/users/me/keys/" + key_id, key=self.key_a)
self.assertEqual(status, 404)
# 撤销 primary(legacy):primary 401;再撤需用另一把 key,
# 结果应为 404(已撤销),且另一把 key 不受影响。
status, body = self._req(
"POST", "/users/me/keys", {"label": "spare"}, key=self.key_a)
self.assertEqual(status, 201, body)
spare_key = body["api_key"]
status, body = self._req(
"DELETE", "/users/me/keys/legacy", key=self.key_a)
self.assertEqual(status, 200, body)
status, _ = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 401)
status, _ = self._req(
"DELETE", "/users/me/keys/legacy", key=spare_key)
self.assertEqual(status, 404)
status, _ = self._req("GET", "/users/me/keys", key=spare_key)
self.assertEqual(status, 200)
def test_revoke_legacy_and_revoke_others(self):
_s, created = self._req(
"POST", "/users/me/keys", {"label": "ci"}, key=self.key_a)
ci_key = created["api_key"]
_s, created2 = self._req(
"POST", "/users/me/keys", {"label": "home"}, key=self.key_a)
home_key = created2["api_key"]
# revoke-others(用 home key 调):primary + ci 被撤销。
status, body = self._req(
"POST", "/users/me/keys/revoke-others", {}, key=home_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["revoked_count"], 2)
status, _ = self._req("GET", "/users/me/keys", key=self.key_a)
self.assertEqual(status, 401)
status, _ = self._req("GET", "/users/me/keys", key=ci_key)
self.assertEqual(status, 401)
status, _ = self._req("GET", "/users/me/keys", key=home_key)
self.assertEqual(status, 200)
# primary 已被 revoke-others 撤销,再撤 legacy 应为 404;
# home key 本身不受影响。
status, body = self._req(
"DELETE", "/users/me/keys/legacy", key=home_key)
self.assertEqual(status, 404, body)
status, _ = self._req("GET", "/users/me/keys", key=home_key)
self.assertEqual(status, 200)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -232,15 +232,16 @@ class BindHttpTestCase(unittest.TestCase):
status, body = self._req(
"GET", "/oauth/github/bindings", key=self.key_b)
self.assertEqual(body["bindings"], [])
# 登录(无 bind 标记的 state)落到被绑账号并轮换 key。
# 登录(无 bind 标记的 state)落到被绑账号并签发附加 key,
# 旧 key 仍可用(多 key,不再轮换)。
status, body = self._callback(self._login_state())
self.assertEqual(status, 200, body)
self.assertEqual(body["user_id"], self.user_a)
self.assertTrue(body["api_key_rotated"])
self.assertFalse(body["api_key_rotated"])
new_key = body["api_key"]
status, _ = self._req(
"GET", "/oauth/github/bindings", key=self.key_a)
self.assertEqual(status, 401)
self.assertEqual(status, 200)
status, _ = self._req(
"GET", "/oauth/github/bindings", key=new_key)
self.assertEqual(status, 200)
+7 -5
View File
@@ -107,7 +107,7 @@ class OAuthRealLoginTestCase(unittest.TestCase):
self.assertIsNotNone(match, page)
return match.group(1)
# -- GET 回调成功:发 key、key 可用、轮换旧 key 失效 -------------
# -- GET 回调成功:发 key、旧 key 不失效(多 key) ---------------
def test_get_callback_success_issues_key_and_rotation(self):
state = self._login_state()
status, page = self._req(
@@ -121,20 +121,22 @@ class OAuthRealLoginTestCase(unittest.TestCase):
user_id = self.auth.authenticate(key1)
self.assertIsNotNone(user_id)
# 同一身份第二次登录:新 key、旧 key 失效、审计有轮换记录。
# 同一身份第二次登录:新 key 是附加 key,旧 key 仍可用,
# 审计记 api_key_issued(不再轮换、不再记 api_key_rotated)。
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)
self.assertIn("Previous keys still work", page2)
key2 = self._api_key_in_page(page2)
self.assertNotEqual(key1, key2)
self.assertIsNone(self.auth.authenticate(key1))
self.assertEqual(self.auth.authenticate(key1), user_id)
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)
self.assertIn("api_key_issued", actions)
self.assertNotIn("api_key_rotated", actions)
def test_post_callback_success_returns_key_once(self):
state = self._login_state()