From 7b85b4a4fdd6f7619f339affe674b797450ceb42 Mon Sep 17 00:00:00 2001
From: host Your previous API key has been rotated and no longer works. Previous keys still work (multi-key). Revoke unused keys "
+ "via the API keys management endpoints. Please save it immediately. It will not be shown again "
"after you leave or refresh this page."
- "%s%s
Use it as the X-API-Key header for API calls.
" "" % (html.escape(str(provider)), @@ -1930,8 +1960,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): 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 "")) + html.escape(str(api_key_plain)))) def _oauth_bind_success_page(self, provider, login): """绑定成功页:只显示 provider + 外部 login + 成功提示。 @@ -2266,6 +2295,75 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): conn.close() self._send_json(200, status) + # -- API keys 管理 --------------------------------------------------- + def _user_keys_list(self): + """列出自己的 key:primary(id "legacy")+ 附加 key。 + + 只返回 id / label / 后 4 位提示 / 创建 / 最近使用 / 撤销 + 时间,绝不返回明文与哈希;只看得到自己的(认证用户)。 + """ + user_id = self._authenticate() + if user_id is None: + self._send_json(401, {"error": "unauthorized"}) + return + with _auth_store(self.server.db_path) as auth: + keys = auth.list_api_keys(user_id) + self._send_json(200, {"keys": keys}) + + def _user_keys_create(self): + """给自己签发一个附加 key:明文只在本次响应里出现一次。 + + body 可含 label(字符串,缺省 "api-key");新 key 立刻 + 可用,与 primary 及已有附加 key 互不影响。 + """ + user_id = self._authenticate() + if user_id is None: + self._send_json(401, {"error": "unauthorized"}) + return + body, err = self._read_json() + if err: + body = {} + label = (body or {}).get("label") + if not isinstance(label, str) or not label: + label = "api-key" + with _auth_store(self.server.db_path) as auth: + key_id, api_key_plain = auth.create_api_key(user_id, label) + self._send_json(201, {"id": key_id, "label": label, + "hint": api_key_plain[-4:], + "api_key": api_key_plain}) + + def _user_keys_revoke(self, key_id): + """撤销自己的一个 key:附加 key 记 revoked_at,primary + (id "legacy")清哈希;无此 key 或已撤销返回 404。 + + 撤销自己的最后一个 key 后须重新登录拿新 key(登录总能 + 签发,不会把自己锁死在账号外)。 + """ + user_id = self._authenticate() + if user_id is None: + self._send_json(401, {"error": "unauthorized"}) + return + with _auth_store(self.server.db_path) as auth: + ok = auth.revoke_api_key(user_id, key_id) + if not ok: + self._send_json(404, {"error": "key not found"}) + return + self._send_json(200, {"revoked": True, "id": key_id}) + + def _user_keys_revoke_others(self): + """撤销除本次请求 key 之外的全部自己的 key(附加 + primary), + 返回撤销数。用于“这台设备之外的 key 全作废”;本次请求 + 的 key 本身不受影响。""" + user_id = self._authenticate() + if user_id is None: + self._send_json(401, {"error": "unauthorized"}) + return + api_key = self.headers.get("X-API-Key") or "" + keep_hash = hashlib.sha256(api_key.encode()).hexdigest() + with _auth_store(self.server.db_path) as auth: + count = auth.revoke_other_api_keys(user_id, keep_hash) + self._send_json(200, {"revoked_count": count}) + # -- 推送订阅与分发 --------------------------------------------------- def _push_create_endpoint(self, user_id, project_id): body, err = self._read_json() diff --git a/cloud/tests/test_api_keys.py b/cloud/tests/test_api_keys.py new file mode 100644 index 0000000..11c2d55 --- /dev/null +++ b/cloud/tests/test_api_keys.py @@ -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() diff --git a/cloud/tests/test_oauth_bind.py b/cloud/tests/test_oauth_bind.py index 362baeb..39dec50 100644 --- a/cloud/tests/test_oauth_bind.py +++ b/cloud/tests/test_oauth_bind.py @@ -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) diff --git a/cloud/tests/test_oauth_real_login.py b/cloud/tests/test_oauth_real_login.py index 522c03b..6e6d31b 100644 --- a/cloud/tests/test_oauth_real_login.py +++ b/cloud/tests/test_oauth_real_login.py @@ -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()