多 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

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