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