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