多 key:OAuth 登录签发附加 key 不再轮换,keys 管理端点
This commit is contained in:
1 parent
aa5ad41eba
commit
7b85b4a4fd
7 files changed
+544
-37
No files matched your search
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user