多 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()