多 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

+17 -4
View File
@@ -135,10 +135,23 @@ client_secret(只走环境变量),不是无认证后门。
只绑 `127.0.0.1:8765`,没有公网入口、没有 TLS,这一步是部署
前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到
server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。
- 登录后 API key 的保存与轮换规则:新用户拿到创建时生成的 key;
已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效),
因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/
响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 key。
- 登录后 API key 的保存与多 key 规则:新用户拿到创建时生成的
primary key;已存在用户每次 OAuth 登录签发一个附加 key
(label 为 `oauth:<provider>`),旧 key(primary 与之前的附加
key)全部继续有效,不再轮换——登录不会打断已存得到处都是的
旧 key。响应里的 `api_key_rotated` 字段为向后兼容保留,恒为
False;签发附加 key 时记审计 `api_key_issued`(不再记
`api_key_rotated`)。key 明文只在回调成功页/响应里出现一次,
丢了只能重新走一次 OAuth 登录拿新 key(旧 key 不受影响)。
- API keys 管理(全部只操作认证用户自己的 key,须持本站 key):
`GET /users/me/keys`(列出 primary(id 为 "legacy")+ 附加 key,
含 label、后 4 位提示、创建/最近使用/撤销时间,绝不返回明文
与哈希)、`POST /users/me/keys`(body 可含 label,签发附加 key,
明文只在本次响应里出现一次)、`DELETE /users/me/keys/{id}`
(撤销一个 key,primary 用 id "legacy",无此 key 或已撤销返回
404)、`POST /users/me/keys/revoke-others`(撤销除本次请求 key
之外的全部自己的 key,返回撤销数)。附加 key 认证成功时更新
last_used_at,但每小时最多写一次(认证是热路径)。
- 已有本站账号的绑定流程(已实现):持本站 key 调
`POST /oauth/{provider}/bind/initiate`(body 含 client_id +
redirect_uri),返回 `{provider, state, authorization_url}`,
+160 -5
View File
@@ -85,10 +85,15 @@ class AuthStore:
return user_id, api_key_plain
def rotate_api_key(self, user_id):
"""轮换用户的 API key,返回新明文 key(只返回这一次)。
"""轮换用户的 primary key,返回新明文 key(只返回这一次)。
旧 key 的哈希即被替换,轮换后旧 key 立即失效。
用户不存在时抛 ValueError(调用方不得用它凭空建用户)。
旧 primary key 的哈希即被替换,轮换后旧 primary 立即失效;
附加 key(api_keys 表)不受影响。用户不存在时抛 ValueError
(调用方不得用它凭空建用户)。
注意:OAuth 登录不再调本方法(登录改为签发附加 key,旧 key
不失效,见 create_api_key);本方法只留给“明确要轮换
primary”的显式操作。
"""
row = self.conn.execute(
"SELECT id FROM users WHERE id = ?", (user_id,)
@@ -104,15 +109,165 @@ class AuthStore:
self.conn.commit()
return api_key_plain
def _ensure_api_keys_table(self):
"""建附加 key 表(幂等):一行一个 key,只存哈希 + 后 4 位
提示,绝不存明文;revoked_at 非空即已撤销,认证时跳过。"""
self.conn.execute(
"CREATE TABLE IF NOT EXISTS api_keys("
"id TEXT PRIMARY KEY, "
"user_id TEXT, "
"key_hash TEXT UNIQUE, "
"label TEXT, "
"hint TEXT, "
"created_at REAL, "
"last_used_at REAL, "
"revoked_at REAL)"
)
self.conn.commit()
def create_api_key(self, user_id, label):
"""给用户签发一个附加 key,返回 (key_id, 明文 key)。
明文只返回这一次;库里只存哈希与后 4 位提示。附加 key 与
primary key(users.api_key_hash)互相独立:撤销、轮换一
方不影响另一方。用户不存在时抛 ValueError。
"""
row = self.conn.execute(
"SELECT id FROM users WHERE id = ?", (user_id,)
).fetchone()
if row is None:
raise ValueError("unknown user: %r" % (user_id,))
self._ensure_api_keys_table()
for _ in range(5):
api_key_plain = secrets.token_urlsafe(32)
api_key_hash = hashlib.sha256(
api_key_plain.encode()).hexdigest()
exists = self.conn.execute(
"SELECT id FROM api_keys WHERE key_hash = ?",
(api_key_hash,),
).fetchone()
if exists is None:
break
else:
raise RuntimeError("failed to generate a unique api key")
key_id = secrets.token_hex(8)
self.conn.execute(
"INSERT INTO api_keys(id, user_id, key_hash, label, hint,"
" created_at, last_used_at, revoked_at)"
" VALUES(?, ?, ?, ?, ?, ?, NULL, NULL)",
(key_id, user_id, api_key_hash, label,
api_key_plain[-4:], time.time()),
)
self.conn.commit()
return key_id, api_key_plain
def authenticate(self, api_key):
"""用 API key 认证,成功返回 user_id,失败返回 None。"""
"""用 API key 认证,成功返回 user_id,失败返回 None。
先查 primary(users.api_key_hash),再查附加 key(api_keys
表,未撤销的才算)。附加 key 认证成功时顺手更新
last_used_at,但每小时最多写一次(认证是热路径,不为
每次请求都写库)。
"""
if not api_key:
return None
api_key_hash = hashlib.sha256(api_key.encode()).hexdigest()
row = self.conn.execute(
"SELECT id FROM users WHERE api_key_hash = ?", (api_key_hash,)
).fetchone()
return row[0] if row else None
if row:
return row[0]
self._ensure_api_keys_table()
row = self.conn.execute(
"SELECT id, user_id, last_used_at FROM api_keys"
" WHERE key_hash = ? AND revoked_at IS NULL",
(api_key_hash,),
).fetchone()
if row is None:
return None
now = time.time()
if row[2] is None or now - row[2] >= 3600:
self.conn.execute(
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
(now, row[0]),
)
self.conn.commit()
return row[1]
def list_api_keys(self, user_id):
"""列出用户全部 key(只看自己的,调用方须先认证)。
返回 dict 列表:primary(id 为 "legacy",即建用户时发的
key,没有 hint)+ 附加 key(按创建时间排序);已撤销的也
列出(revoked_at 非空),调用方按此判断。绝不返回明文、
不返回哈希。
"""
self._ensure_api_keys_table()
keys = []
row = self.conn.execute(
"SELECT api_key_hash, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
if row is not None and row[0]:
keys.append({"id": "legacy", "label": "primary", "hint": None,
"created_at": row[1], "last_used_at": None,
"revoked_at": None})
for r in self.conn.execute(
"SELECT id, label, hint, created_at, last_used_at,"
" revoked_at FROM api_keys WHERE user_id = ?"
" ORDER BY created_at",
(user_id,)).fetchall():
keys.append({"id": r[0], "label": r[1], "hint": r[2],
"created_at": r[3], "last_used_at": r[4],
"revoked_at": r[5]})
return keys
def revoke_api_key(self, user_id, key_id):
"""撤销一个 key,成功返回 True,无此 key 或已撤销返回 False。
key_id 为 "legacy" 时清 primary(users.api_key_hash 置空);
其它 id 撤销附加 key 行(revoked_at 置当前时间,不删行,
审计与列表仍可见)。撤销自己的最后一个 key 后须重新登录
拿新 key,调用方应把这点写进响应提示。
"""
self._ensure_api_keys_table()
if key_id == "legacy":
cur = self.conn.execute(
"UPDATE users SET api_key_hash = NULL"
" WHERE id = ? AND api_key_hash IS NOT NULL",
(user_id,),
)
self.conn.commit()
return cur.rowcount > 0
cur = self.conn.execute(
"UPDATE api_keys SET revoked_at = ?"
" WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
(time.time(), key_id, user_id),
)
self.conn.commit()
return cur.rowcount > 0
def revoke_other_api_keys(self, user_id, keep_hash):
"""撤销除 keep_hash 之外的全部 key(附加 + primary),
返回撤销数。用于“这台设备之外的 key 全作废”:调用方传
本次请求 key 的哈希(即保留的那一个),其余一律撤销。"""
self._ensure_api_keys_table()
cur = self.conn.execute(
"UPDATE api_keys SET revoked_at = ?"
" WHERE user_id = ? AND key_hash != ?"
" AND revoked_at IS NULL",
(time.time(), user_id, keep_hash),
)
count = cur.rowcount
cur = self.conn.execute(
"UPDATE users SET api_key_hash = NULL"
" WHERE id = ? AND api_key_hash IS NOT NULL"
" AND api_key_hash != ?",
(user_id, keep_hash),
)
count += cur.rowcount
self.conn.commit()
return count
def create_project(self, project_id, owner_user_id):
"""创建项目成员记录:指定用户为该项目的 owner。"""
+9 -6
View File
@@ -602,12 +602,14 @@ def map_external_identity_to_local_user_with_key(auth_store, provider,
返回 ``(user_id, created, api_key_plain, rotated)``:
- 新用户:经 ``AuthStore.create_user`` 创建,返回创建时生成的
key,``rotated`` 为 False;
- 已存在用户:经 ``AuthStore.rotate_api_key`` 轮换,返回新 key,
旧 key 即失效,``rotated`` 为 True;
primary key,``rotated`` 为 False;
- 已存在用户:经 ``AuthStore.create_api_key`` 签发一个附加 key
(label 为 ``oauth:<provider>``),旧 key(primary 与之前
的附加 key)全部继续有效,``rotated`` 恒为 False;
调用方须把明文 key 只在回调成功页/响应里展示一次,之后任何
接口都不再返回它;审计由 server 层记(oauth_login 恒记,
轮换时另记 api_key_rotated)。
签发附加 key 时另记 api_key_issued)。``rotated`` 字段只为
向后兼容保留,恒为 False——登录不再轮换任何 key。
"""
_provider_config(provider)
login = identity.get("login") or identity.get("external_id") \
@@ -625,8 +627,9 @@ def map_external_identity_to_local_user_with_key(auth_store, provider,
# 新用户:直接返回创建时生成的 key,不再多轮换一次。
user_id, api_key_plain = auth_store.create_user(username)
return user_id, True, api_key_plain, False
api_key_plain = auth_store.rotate_api_key(row[0])
return row[0], False, api_key_plain, True
_key_id, api_key_plain = auth_store.create_api_key(
row[0], "oauth:%s" % provider)
return row[0], False, api_key_plain, False
def _binding_key(identity):
+112 -14
View File
@@ -577,6 +577,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/users/me/keys"):
# API keys 管理:全部只操作认证用户自己的 key。
if method == "GET" and path == "/users/me/keys":
self._user_keys_list()
elif method == "POST" and path == "/users/me/keys":
self._user_keys_create()
elif method == "POST" and \
path == "/users/me/keys/revoke-others":
self._user_keys_revoke_others()
elif method == "DELETE" and \
path.startswith("/users/me/keys/"):
key_id = path[len("/users/me/keys/"):]
if key_id and "/" not in key_id:
self._user_keys_revoke(key_id)
else:
self._send_json(404, {"error": "not found"})
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/github-app/"):
# GitHub App 一键安装:setup/state 与 status 须持本站 key
# (在处理器内验);setup 表单页与 callback 靠 setup
@@ -1801,9 +1820,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
账号,返回 is_bind=True、api_key_plain=None(绑定成功页
绝不显示任何 key);冲突抛 BindError;
2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按
OAuth 登录规矩轮换 key(created=False、rotated=True),
与 oauth 账号登录同一语义,不发明新语义;
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 轮换)。
OAuth 登录规矩签发附加 key(created=False、rotated
恒为 False),与 oauth 账号登录同一语义,不发明新语义;
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 签发
附加 key)。
"""
store = OAuthStateStore(self.server.db_path)
try:
@@ -1827,19 +1847,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return (bind_user_id, False, None, False, identity, True)
bound_user_id = find_bound_user(auth, provider, identity)
if bound_user_id is not None:
api_key_plain = auth.rotate_api_key(bound_user_id)
_key_id, api_key_plain = auth.create_api_key(
bound_user_id, "oauth:%s" % provider)
auth.audit(bound_user_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
auth.audit(bound_user_id, "", "api_key_rotated", provider)
return (bound_user_id, False, api_key_plain, True, identity,
auth.audit(bound_user_id, "", "api_key_issued", provider)
return (bound_user_id, False, api_key_plain, False, identity,
False)
local_id, created, api_key_plain, rotated = \
map_external_identity_to_local_user_with_key(
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
if rotated:
auth.audit(local_id, "", "api_key_rotated", provider)
if not created:
auth.audit(local_id, "", "api_key_issued", provider)
return local_id, created, api_key_plain, rotated, identity, False
def _oauth_callback(self, path):
@@ -1880,7 +1901,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
identity.get("already_bound", False)})
return
# api_key 明文只在这里出现一次:调用方须立即保存,
# 之后任何接口都不再返回它;轮换时旧 key 已失效。
# 之后任何接口都不再返回它;登录签发的是附加 key,旧 key
# 仍可继续使用(多 key;不用的旧 key 可在 keys 管理里撤销)。
self._send_json(200, {"user_id": local_id, "created": created,
"provider": provider,
"login": identity.get("login"),
@@ -1905,10 +1927,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _oauth_success_page(self, provider, local_id, created, login,
api_key_plain, rotated):
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
一次;绝不显示 access_token、client_secret、旧 API key。"""
一次;绝不显示 access_token、client_secret、旧 API key。
rotated 只为向后兼容保留(登录不再轮换,恒为 False):
新 key 是附加 key,旧 key 仍可继续使用,页面不再写“旧 key
已失效”,而是说明多 key 与撤销入口。
"""
rotation_note = (
"<p>Your previous API key has been rotated and no longer works.</p>"
if rotated else "")
if rotated else
"<p>Previous keys still work (multi-key). Revoke unused keys "
"via the API keys management endpoints.</p>"
if not created else "")
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login succeeded</title></head><body>"
@@ -1922,7 +1952,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"<p><code>%s</code></p>"
"<p>Please save it immediately. It will not be shown again "
"after you leave or refresh this page."
"%s</p>"
"</p>"
"<p>Use it as the X-API-Key header for API calls.</p>"
"</body></html>"
% (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()
+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()