KairoCore 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of KairoCore might be problematic. Click here for more details.
- KairoCore/__init__.py +9 -2
- KairoCore/common/errors.py +39 -1
- KairoCore/docs/CodeGenerateDoc.md +58 -0
- KairoCore/docs/FileUploadDoc.md +142 -0
- KairoCore/docs/HttpSessionDoc.md +170 -0
- KairoCore/docs/TokenUseDoc.md +349 -0
- KairoCore/docs/UseDoc.md +174 -0
- KairoCore/example/your_project_name/action/api_key_admin.py +42 -0
- KairoCore/example/your_project_name/action/auth.py +105 -0
- KairoCore/example/your_project_name/action/file_upload.py +71 -0
- KairoCore/example/your_project_name/action/http_demo.py +64 -0
- KairoCore/example/your_project_name/action/protected_demo.py +85 -0
- KairoCore/example/your_project_name/schema/auth.py +14 -0
- KairoCore/extensions/baidu/yijian.py +0 -0
- KairoCore/utils/auth.py +629 -0
- KairoCore/utils/kc_http.py +260 -0
- KairoCore/utils/kc_upload.py +218 -0
- KairoCore/utils/panic.py +21 -1
- KairoCore/utils/router.py +2 -1
- {kairocore-1.0.0.dist-info → kairocore-1.2.0.dist-info}/METADATA +5 -1
- {kairocore-1.0.0.dist-info → kairocore-1.2.0.dist-info}/RECORD +23 -8
- {kairocore-1.0.0.dist-info → kairocore-1.2.0.dist-info}/WHEEL +0 -0
- {kairocore-1.0.0.dist-info → kairocore-1.2.0.dist-info}/top_level.txt +0 -0
KairoCore/utils/auth.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KairoAuth 认证与授权工具模块
|
|
3
|
+
|
|
4
|
+
该模块提供:
|
|
5
|
+
- JWT 编解码与签名(HS256)
|
|
6
|
+
- Access Token / Refresh Token 的签发、校验与轮换
|
|
7
|
+
- 请求上下文主体注入(基于 ContextVar)
|
|
8
|
+
- API_KEY 的生成、读取、校验与删除(用于简单的永久密钥接入)
|
|
9
|
+
- FastAPI 路由依赖:基于 Access Token、API_KEY、角色、租户等的访问控制
|
|
10
|
+
|
|
11
|
+
设计理念:全部使用静态方法,调用方无需实例化,便于在项目各处直接引用。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import base64
|
|
19
|
+
import uuid
|
|
20
|
+
import hashlib
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
22
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
23
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
from contextvars import ContextVar
|
|
26
|
+
|
|
27
|
+
from fastapi import Request
|
|
28
|
+
from ..utils.panic import Panic
|
|
29
|
+
from ..common.errors import (
|
|
30
|
+
KCAUTH_TOKEN_INVALID,
|
|
31
|
+
KCAUTH_TOKEN_EXPIRED,
|
|
32
|
+
KCAUTH_REFRESH_INVALID,
|
|
33
|
+
KCAUTH_REFRESH_EXPIRED,
|
|
34
|
+
KCAUTH_TOKEN_REVOKED,
|
|
35
|
+
KCAUTH_PERMISSION_DENIED,
|
|
36
|
+
KCAUTH_TENANT_REQUIRED,
|
|
37
|
+
KCAUTH_ROLE_REQUIRED,
|
|
38
|
+
KCAUTH_CONFIG_ERROR,
|
|
39
|
+
KCAUTH_LOGIN_FAILED,
|
|
40
|
+
)
|
|
41
|
+
from ..utils.log import get_logger
|
|
42
|
+
import secrets
|
|
43
|
+
|
|
44
|
+
logger = get_logger()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class KairoAuth:
|
|
48
|
+
"""认证与授权工具类(全部为静态方法)。
|
|
49
|
+
|
|
50
|
+
用途:
|
|
51
|
+
- 对外提供 JWT 相关能力与访问控制的依赖函数
|
|
52
|
+
- 通过 ContextVar 保存当前请求主体(principal),避免显式传递
|
|
53
|
+
|
|
54
|
+
使用方式:
|
|
55
|
+
- 直接调用 KairoAuth.issue_access_token(...) / verify_access_token(...)
|
|
56
|
+
- 在路由依赖中使用 KairoAuth.require_access_token / require_access_or_api_key 等
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# 当前请求主体上下文(在依赖中写入,在业务处理函数中读取)
|
|
60
|
+
_current_principal: ContextVar[Optional[Dict[str, Any]]] = ContextVar(
|
|
61
|
+
"_current_principal", default=None
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 简易的 refresh token 存储(示例用途;生产环境建议换为 Redis/DB)
|
|
65
|
+
_REFRESH_STORE: Dict[str, Dict[str, Any]] = {}
|
|
66
|
+
|
|
67
|
+
# --- 上下文 ---
|
|
68
|
+
@staticmethod
|
|
69
|
+
def set_current_principal(principal: Dict[str, Any]) -> None:
|
|
70
|
+
"""将主体信息写入上下文,供后续业务读取。"""
|
|
71
|
+
KairoAuth._current_principal.set(principal)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def get_current_principal() -> Optional[Dict[str, Any]]:
|
|
75
|
+
"""获取当前请求的主体信息,如果尚未注入则返回 None。"""
|
|
76
|
+
return KairoAuth._current_principal.get()
|
|
77
|
+
|
|
78
|
+
# --- JWT 基础 ---
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _b64url_encode(data: bytes) -> str:
|
|
81
|
+
"""以 URL 安全的 Base64 方式编码,并去除尾部的'='填充。"""
|
|
82
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _b64url_decode(data: str) -> bytes:
|
|
86
|
+
"""解码 URL 安全的 Base64 字符串,自动补齐缺失的'='填充。"""
|
|
87
|
+
padding = "=" * (-len(data) % 4)
|
|
88
|
+
return base64.urlsafe_b64decode((data + padding).encode())
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _sign_hs256(secret: str, signing_input: bytes) -> str:
|
|
92
|
+
"""使用 HS256 计算签名并返回 base64url 编码的签名串。"""
|
|
93
|
+
sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
|
|
94
|
+
return KairoAuth._b64url_encode(sig)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _jwt_encode(payload: Dict[str, Any], secret: str) -> str:
|
|
98
|
+
"""将负载编码为 JWT 字符串(header+payload+signature)。"""
|
|
99
|
+
header = {"alg": "HS256", "typ": "JWT"}
|
|
100
|
+
# 使用无空格的紧凑 JSON 以减小长度
|
|
101
|
+
header_b64 = KairoAuth._b64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
|
102
|
+
payload_b64 = KairoAuth._b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
|
103
|
+
signing_input = f"{header_b64}.{payload_b64}".encode()
|
|
104
|
+
signature_b64 = KairoAuth._sign_hs256(secret, signing_input)
|
|
105
|
+
return f"{header_b64}.{payload_b64}.{signature_b64}"
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _jwt_decode(token: str, secret: str) -> Dict[str, Any]:
|
|
109
|
+
"""解析并校验 JWT,返回 payload。
|
|
110
|
+
|
|
111
|
+
- 校验签名是否匹配(使用固定时间比较防止时序攻击)
|
|
112
|
+
- 校验 exp 是否过期(如果存在 exp 字段)
|
|
113
|
+
- 解析 payload JSON 并返回
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
parts = token.split(".")
|
|
117
|
+
if len(parts) != 3:
|
|
118
|
+
raise KCAUTH_TOKEN_INVALID
|
|
119
|
+
header_b64, payload_b64, signature_b64 = parts
|
|
120
|
+
signing_input = f"{header_b64}.{payload_b64}".encode()
|
|
121
|
+
expected_sig = KairoAuth._sign_hs256(secret, signing_input)
|
|
122
|
+
# 固定时间比较,避免泄露签名匹配时长差异
|
|
123
|
+
if not hmac.compare_digest(signature_b64, expected_sig):
|
|
124
|
+
raise KCAUTH_TOKEN_INVALID
|
|
125
|
+
payload = json.loads(KairoAuth._b64url_decode(payload_b64))
|
|
126
|
+
# exp 校验(单位秒)
|
|
127
|
+
now = KairoAuth._now()
|
|
128
|
+
if "exp" in payload and now >= int(payload["exp"]):
|
|
129
|
+
raise KCAUTH_TOKEN_EXPIRED
|
|
130
|
+
return payload
|
|
131
|
+
except Panic:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as e:
|
|
134
|
+
# 包括 JSON 解析或 base64 解析异常
|
|
135
|
+
raise KCAUTH_TOKEN_INVALID.msg_format(str(e)) from e
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _env(name: str, default: Optional[str] = None) -> str:
|
|
139
|
+
"""读取环境变量,若缺失且未提供默认值则抛出配置错误。"""
|
|
140
|
+
val = os.getenv(name, default)
|
|
141
|
+
if val is None:
|
|
142
|
+
raise KCAUTH_CONFIG_ERROR.msg_format(f"缺少环境变量 {name}")
|
|
143
|
+
return val
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _now() -> int:
|
|
147
|
+
"""返回当前时间戳(秒)。"""
|
|
148
|
+
return int(time.time())
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _gen_jti() -> str:
|
|
152
|
+
"""生成刷新令牌的唯一 ID(jti)。"""
|
|
153
|
+
return uuid.uuid4().hex
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _refresh_put(jti: str, user_id: str, tenant_id: Optional[str], exp_ts: int) -> None:
|
|
157
|
+
"""将刷新令牌信息写入内存存储。
|
|
158
|
+
字段说明:uid 用户ID、tid 租户ID、exp 过期时间戳、revoked 是否已撤销。
|
|
159
|
+
"""
|
|
160
|
+
KairoAuth._REFRESH_STORE[jti] = {"uid": user_id, "tid": tenant_id, "exp": exp_ts, "revoked": False}
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _refresh_get(jti: str) -> Optional[Dict[str, Any]]:
|
|
164
|
+
"""读取指定 jti 的刷新令牌记录,不存在则返回 None。"""
|
|
165
|
+
return KairoAuth._REFRESH_STORE.get(jti)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _refresh_revoke(jti: str) -> None:
|
|
169
|
+
"""撤销指定 jti 的刷新令牌(标记 revoked=True)。"""
|
|
170
|
+
item = KairoAuth._REFRESH_STORE.get(jti)
|
|
171
|
+
if item:
|
|
172
|
+
item["revoked"] = True
|
|
173
|
+
|
|
174
|
+
# --- 签发 ---
|
|
175
|
+
@staticmethod
|
|
176
|
+
def issue_access_token(
|
|
177
|
+
user_id: str,
|
|
178
|
+
tenant_id: Optional[str],
|
|
179
|
+
roles: List[str],
|
|
180
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
181
|
+
) -> Tuple[str, int]:
|
|
182
|
+
"""签发访问令牌(access token)。
|
|
183
|
+
|
|
184
|
+
参数:
|
|
185
|
+
- user_id: 用户唯一标识
|
|
186
|
+
- tenant_id: 租户标识(可以为空)
|
|
187
|
+
- roles: 用户角色列表
|
|
188
|
+
- extra: 额外的负载字段(会写入 JWT payload)
|
|
189
|
+
|
|
190
|
+
返回:
|
|
191
|
+
- (token, exp_ts) 二元组,其中 exp_ts 为过期时间戳(秒)
|
|
192
|
+
"""
|
|
193
|
+
secret = KairoAuth._env("JWT_SECRET", "dev-secret")
|
|
194
|
+
iss = os.getenv("JWT_ISS", "KairoCore")
|
|
195
|
+
aud = os.getenv("JWT_AUD", "KairoCoreClients")
|
|
196
|
+
ttl = int(os.getenv("ACCESS_TOKEN_TTL_SECONDS", "900")) # 默认 15 分钟
|
|
197
|
+
|
|
198
|
+
now = KairoAuth._now()
|
|
199
|
+
payload = {
|
|
200
|
+
"sub": user_id,
|
|
201
|
+
"tid": tenant_id,
|
|
202
|
+
"roles": roles or [],
|
|
203
|
+
"iat": now,
|
|
204
|
+
"exp": now + ttl,
|
|
205
|
+
"iss": iss,
|
|
206
|
+
"aud": aud,
|
|
207
|
+
"type": "access",
|
|
208
|
+
}
|
|
209
|
+
if extra:
|
|
210
|
+
payload.update(extra)
|
|
211
|
+
|
|
212
|
+
token = KairoAuth._jwt_encode(payload, secret)
|
|
213
|
+
return token, payload["exp"]
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def issue_refresh_token(user_id: str, tenant_id: Optional[str]) -> Tuple[str, str, int]:
|
|
217
|
+
"""签发刷新令牌(refresh token)并存储其 jti 记录。
|
|
218
|
+
|
|
219
|
+
返回:(token, jti, exp_ts)
|
|
220
|
+
"""
|
|
221
|
+
secret = KairoAuth._env("JWT_SECRET", "dev-secret")
|
|
222
|
+
iss = os.getenv("JWT_ISS", "KairoCore")
|
|
223
|
+
aud = os.getenv("JWT_AUD", "KairoCoreClients")
|
|
224
|
+
ttl = int(os.getenv("REFRESH_TOKEN_TTL_SECONDS", "1209600")) # 默认 14 天
|
|
225
|
+
|
|
226
|
+
now = KairoAuth._now()
|
|
227
|
+
jti = KairoAuth._gen_jti()
|
|
228
|
+
payload = {
|
|
229
|
+
"sub": user_id,
|
|
230
|
+
"tid": tenant_id,
|
|
231
|
+
"iat": now,
|
|
232
|
+
"exp": now + ttl,
|
|
233
|
+
"iss": iss,
|
|
234
|
+
"aud": aud,
|
|
235
|
+
"type": "refresh",
|
|
236
|
+
"jti": jti,
|
|
237
|
+
}
|
|
238
|
+
token = KairoAuth._jwt_encode(payload, secret)
|
|
239
|
+
KairoAuth._refresh_put(jti, user_id, tenant_id, payload["exp"])
|
|
240
|
+
return token, jti, payload["exp"]
|
|
241
|
+
|
|
242
|
+
# --- 校验 ---
|
|
243
|
+
@staticmethod
|
|
244
|
+
def verify_access_token(token: str) -> Dict[str, Any]:
|
|
245
|
+
"""校验访问令牌(类型必须为 access),返回 payload。"""
|
|
246
|
+
secret = KairoAuth._env("JWT_SECRET", "dev-secret")
|
|
247
|
+
payload = KairoAuth._jwt_decode(token, secret)
|
|
248
|
+
if payload.get("type") != "access":
|
|
249
|
+
raise KCAUTH_TOKEN_INVALID
|
|
250
|
+
return payload
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def verify_refresh_token(token: str) -> Dict[str, Any]:
|
|
254
|
+
"""校验刷新令牌并核验其 jti 的有效性/未撤销/未过期。"""
|
|
255
|
+
secret = KairoAuth._env("JWT_SECRET", "dev-secret")
|
|
256
|
+
payload = KairoAuth._jwt_decode(token, secret)
|
|
257
|
+
if payload.get("type") != "refresh":
|
|
258
|
+
raise KCAUTH_REFRESH_INVALID
|
|
259
|
+
jti = payload.get("jti")
|
|
260
|
+
if not jti:
|
|
261
|
+
raise KCAUTH_REFRESH_INVALID
|
|
262
|
+
record = KairoAuth._refresh_get(jti)
|
|
263
|
+
if record is None:
|
|
264
|
+
raise KCAUTH_REFRESH_INVALID
|
|
265
|
+
now = KairoAuth._now()
|
|
266
|
+
if record.get("revoked"):
|
|
267
|
+
raise KCAUTH_TOKEN_REVOKED
|
|
268
|
+
if now >= int(record.get("exp", 0)):
|
|
269
|
+
raise KCAUTH_REFRESH_EXPIRED
|
|
270
|
+
return payload
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def rotate_refresh_token(old_jti: str, user_id: str, tenant_id: Optional[str]) -> Tuple[str, str, int]:
|
|
274
|
+
"""刷新令牌轮换:撤销旧 jti 并签发新的刷新令牌。"""
|
|
275
|
+
# 撤销旧的,生成新的
|
|
276
|
+
KairoAuth._refresh_revoke(old_jti)
|
|
277
|
+
return KairoAuth.issue_refresh_token(user_id, tenant_id)
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def revoke_refresh_token(jti: str) -> None:
|
|
281
|
+
"""撤销指定的刷新令牌(登出或安全事件场景)。"""
|
|
282
|
+
KairoAuth._refresh_revoke(jti)
|
|
283
|
+
|
|
284
|
+
# --- 依赖 ---
|
|
285
|
+
@staticmethod
|
|
286
|
+
async def require_access_token(request: Request) -> None:
|
|
287
|
+
"""依赖函数:要求请求提供有效的 Access Token。
|
|
288
|
+
|
|
289
|
+
读取顺序:
|
|
290
|
+
- 优先读取 Header: Authorization: Bearer <token>
|
|
291
|
+
- 回退读取 Cookie: access_token(浏览器场景)
|
|
292
|
+
校验通过后将 payload 注入当前上下文。
|
|
293
|
+
"""
|
|
294
|
+
# 从 Authorization: Bearer xxx 或 Cookie: access_token 获取 access token 并验证,注入上下文
|
|
295
|
+
token: Optional[str] = None
|
|
296
|
+
auth = request.headers.get("Authorization") or ""
|
|
297
|
+
if auth.lower().startswith("bearer "):
|
|
298
|
+
token = auth.split(" ", 1)[1].strip()
|
|
299
|
+
else:
|
|
300
|
+
# 浏览器场景下,支持从 Cookie 读取(如果已由前端或网关设置)
|
|
301
|
+
token = request.cookies.get("access_token")
|
|
302
|
+
if not token:
|
|
303
|
+
raise KCAUTH_TOKEN_INVALID
|
|
304
|
+
payload = KairoAuth.verify_access_token(token)
|
|
305
|
+
KairoAuth.set_current_principal(payload)
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def require_api_key(request: Request) -> None:
|
|
309
|
+
"""依赖函数:要求请求提供有效的永久 API_KEY。
|
|
310
|
+
|
|
311
|
+
从 Header: X-API-Key 或 Query: api_key 读取并校验;
|
|
312
|
+
校验通过后注入一个基于 API_KEY 的主体信息到上下文。
|
|
313
|
+
"""
|
|
314
|
+
# 仅允许使用永久 API_KEY 访问(不校验 Access Token)
|
|
315
|
+
api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
|
|
316
|
+
if not KairoAuth.check_api_key(api_key):
|
|
317
|
+
raise KCAUTH_TOKEN_INVALID
|
|
318
|
+
principal = {
|
|
319
|
+
"sub": "api_key",
|
|
320
|
+
"roles": ["api_key"],
|
|
321
|
+
"tid": None,
|
|
322
|
+
"type": "api_key",
|
|
323
|
+
"iat": KairoAuth._now(),
|
|
324
|
+
"exp": 2**31 - 1,
|
|
325
|
+
}
|
|
326
|
+
KairoAuth.set_current_principal(principal)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def require_roles(required: List[str]) -> Any:
|
|
331
|
+
"""依赖工厂:要求当前主体具备指定角色之一。
|
|
332
|
+
|
|
333
|
+
用法:在路由中 `Depends(KairoAuth.require_roles(["admin"]))`
|
|
334
|
+
"""
|
|
335
|
+
# 路由级角色校验依赖(不向处理函数传值,使用上下文)
|
|
336
|
+
async def _inner() -> None:
|
|
337
|
+
principal = KairoAuth.get_current_principal()
|
|
338
|
+
if principal is None:
|
|
339
|
+
raise KCAUTH_TOKEN_INVALID
|
|
340
|
+
roles = principal.get("roles") or []
|
|
341
|
+
if not any(r in roles for r in required):
|
|
342
|
+
raise KCAUTH_ROLE_REQUIRED.msg_format(f"需要角色: {required}")
|
|
343
|
+
return _inner
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
async def require_tenant() -> None:
|
|
347
|
+
"""依赖函数:要求当前主体包含租户标识(tid)。"""
|
|
348
|
+
principal = KairoAuth.get_current_principal()
|
|
349
|
+
if principal is None:
|
|
350
|
+
raise KCAUTH_TOKEN_INVALID
|
|
351
|
+
if not principal.get("tid"):
|
|
352
|
+
raise KCAUTH_TENANT_REQUIRED
|
|
353
|
+
|
|
354
|
+
# API_KEY 文件路径:默认位于项目根目录(utils 的上级目录)的 .api_key
|
|
355
|
+
_API_KEY_FILE: str = os.getenv(
|
|
356
|
+
"KC_API_KEY_FILE",
|
|
357
|
+
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".api_key")
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def generate_api_key() -> str:
|
|
362
|
+
"""生成并持久化一个永久有效的 API_KEY。
|
|
363
|
+
|
|
364
|
+
优先写入 KC_API_KEY_FILE 指定的文件;如果未配置,则写入项目根目录下 .api_key 文件。
|
|
365
|
+
返回生成的 API_KEY 字符串(即使写入失败也会返回,调用方可自行保存)。
|
|
366
|
+
"""
|
|
367
|
+
key = secrets.token_urlsafe(32)
|
|
368
|
+
try:
|
|
369
|
+
# 确保目录存在
|
|
370
|
+
os.makedirs(os.path.dirname(KairoAuth._API_KEY_FILE), exist_ok=True)
|
|
371
|
+
with open(KairoAuth._API_KEY_FILE, "w", encoding="utf-8") as f:
|
|
372
|
+
f.write(key)
|
|
373
|
+
# 将文件权限设为 600(仅属主可读写),忽略异常以兼容部分环境
|
|
374
|
+
try:
|
|
375
|
+
os.chmod(KairoAuth._API_KEY_FILE, 0o600)
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
logger.info(f"API_KEY 已写入: {KairoAuth._API_KEY_FILE}")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"写入 API_KEY 文件失败: {e}")
|
|
381
|
+
# 写入失败仍返回 key,调用方可自行保存
|
|
382
|
+
return key
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def get_api_key() -> Optional[str]:
|
|
386
|
+
"""获取当前生效的永久 API_KEY。
|
|
387
|
+
|
|
388
|
+
读取顺序:
|
|
389
|
+
- 优先读取环境变量 KC_API_KEY
|
|
390
|
+
- 其次读取 KC_API_KEY_FILE 指定文件或默认文件
|
|
391
|
+
返回:匹配到的 key 字符串或 None。
|
|
392
|
+
"""
|
|
393
|
+
env_key = os.getenv("KC_API_KEY")
|
|
394
|
+
if env_key:
|
|
395
|
+
return env_key.strip()
|
|
396
|
+
try:
|
|
397
|
+
if os.path.exists(KairoAuth._API_KEY_FILE):
|
|
398
|
+
with open(KairoAuth._API_KEY_FILE, "r", encoding="utf-8") as f:
|
|
399
|
+
key = f.read().strip()
|
|
400
|
+
return key or None
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.error(f"读取 API_KEY 文件失败: {e}")
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def delete_api_key() -> bool:
|
|
407
|
+
"""删除持久化的 API_KEY 文件(不影响环境变量 KC_API_KEY)。
|
|
408
|
+
返回是否删除成功或文件不存在(True)。
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
if os.path.exists(KairoAuth._API_KEY_FILE):
|
|
412
|
+
os.remove(KairoAuth._API_KEY_FILE)
|
|
413
|
+
logger.info("API_KEY 文件已删除")
|
|
414
|
+
return True
|
|
415
|
+
except Exception as e:
|
|
416
|
+
logger.error(f"删除 API_KEY 文件失败: {e}")
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def check_api_key(key: Optional[str]) -> bool:
|
|
421
|
+
"""校验传入的 API_KEY 是否匹配当前生效的 API_KEY。"""
|
|
422
|
+
if not key:
|
|
423
|
+
return False
|
|
424
|
+
current = KairoAuth.get_api_key()
|
|
425
|
+
return bool(current) and secrets.compare_digest(current, key.strip())
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
async def require_access_or_api_key(request: Request) -> None:
|
|
429
|
+
"""依赖函数:允许使用 Access Token 或永久 API_KEY 访问。
|
|
430
|
+
|
|
431
|
+
- 从 Header: X-API-Key 或 Query: api_key 读取 API_KEY;如匹配则放行并注入一个 API_KEY 主体。
|
|
432
|
+
- 否则按原 require_access_token 流程校验 Access Token。
|
|
433
|
+
"""
|
|
434
|
+
# 先尝试 API_KEY
|
|
435
|
+
api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
|
|
436
|
+
if KairoAuth.check_api_key(api_key):
|
|
437
|
+
principal = {
|
|
438
|
+
"sub": "api_key",
|
|
439
|
+
"roles": ["api_key"],
|
|
440
|
+
"tid": None,
|
|
441
|
+
"type": "api_key",
|
|
442
|
+
"iat": KairoAuth._now(),
|
|
443
|
+
"exp": 2**31 - 1, # 逻辑上近似永久,不用于过期判断
|
|
444
|
+
}
|
|
445
|
+
KairoAuth.set_current_principal(principal)
|
|
446
|
+
return
|
|
447
|
+
# 回退到 Access Token 校验
|
|
448
|
+
await KairoAuth.require_access_token(request)
|
|
449
|
+
|
|
450
|
+
# =========================
|
|
451
|
+
# 登录口令加密上传与后端解密支持
|
|
452
|
+
# =========================
|
|
453
|
+
@staticmethod
|
|
454
|
+
def _load_rsa_private_key():
|
|
455
|
+
"""加载 RSA 私钥用于密码解密。
|
|
456
|
+
|
|
457
|
+
支持两种配置方式:
|
|
458
|
+
- AUTH_RSA_PRIVATE_KEY_FILE:指向私钥 PEM 文件路径
|
|
459
|
+
- AUTH_RSA_PRIVATE_KEY:环境变量直接存放 PEM 字符串
|
|
460
|
+
可选:AUTH_RSA_PRIVATE_KEY_PASSPHRASE 指定私钥口令
|
|
461
|
+
返回 cryptography 的私钥对象;如未配置或加载失败,返回 None。
|
|
462
|
+
"""
|
|
463
|
+
pem_path = os.getenv("AUTH_RSA_PRIVATE_KEY_FILE")
|
|
464
|
+
pem_inline = os.getenv("AUTH_RSA_PRIVATE_KEY")
|
|
465
|
+
passphrase = os.getenv("AUTH_RSA_PRIVATE_KEY_PASSPHRASE")
|
|
466
|
+
pem_bytes = None
|
|
467
|
+
try:
|
|
468
|
+
if pem_path and os.path.exists(pem_path):
|
|
469
|
+
with open(pem_path, "rb") as f:
|
|
470
|
+
pem_bytes = f.read()
|
|
471
|
+
elif pem_inline:
|
|
472
|
+
pem_bytes = pem_inline.encode()
|
|
473
|
+
if not pem_bytes:
|
|
474
|
+
return None
|
|
475
|
+
key = serialization.load_pem_private_key(
|
|
476
|
+
pem_bytes,
|
|
477
|
+
password=(passphrase.encode() if passphrase else None)
|
|
478
|
+
)
|
|
479
|
+
return key
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.warning(f"加载 RSA 私钥失败: {e}")
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
@staticmethod
|
|
485
|
+
def get_rsa_public_key_pem() -> Optional[str]:
|
|
486
|
+
"""返回 RSA 公钥 PEM(SubjectPublicKeyInfo),用于前端加密。
|
|
487
|
+
|
|
488
|
+
依赖私钥存在以派生公钥;如未配置或失败,返回 None。
|
|
489
|
+
"""
|
|
490
|
+
try:
|
|
491
|
+
private_key = KairoAuth._load_rsa_private_key()
|
|
492
|
+
if private_key is None:
|
|
493
|
+
return None
|
|
494
|
+
public_key = private_key.public_key()
|
|
495
|
+
pem = public_key.public_bytes(
|
|
496
|
+
encoding=serialization.Encoding.PEM,
|
|
497
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
498
|
+
)
|
|
499
|
+
return pem.decode("utf-8")
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.warning(f"生成 RSA 公钥失败: {e}")
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
@staticmethod
|
|
505
|
+
def _load_aes_secret_key() -> Optional[bytes]:
|
|
506
|
+
"""加载 AES-GCM 的共享密钥(Base64 编码)。
|
|
507
|
+
|
|
508
|
+
环境变量:LOGIN_PASSWORD_SECRET_KEY(Base64)
|
|
509
|
+
要求密钥长度为 16/24/32 字节(分别对应 AES-128/192/256)。
|
|
510
|
+
返回 bytes;无或错误时返回 None。
|
|
511
|
+
"""
|
|
512
|
+
k_b64 = os.getenv("LOGIN_PASSWORD_SECRET_KEY")
|
|
513
|
+
if not k_b64:
|
|
514
|
+
return None
|
|
515
|
+
try:
|
|
516
|
+
key = base64.b64decode(k_b64)
|
|
517
|
+
if len(key) not in (16, 24, 32):
|
|
518
|
+
logger.warning("LOGIN_PASSWORD_SECRET_KEY 长度非法,需 16/24/32 字节(Base64)")
|
|
519
|
+
return None
|
|
520
|
+
return key
|
|
521
|
+
except Exception as e:
|
|
522
|
+
logger.warning(f"解析 LOGIN_PASSWORD_SECRET_KEY 失败: {e}")
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
@staticmethod
|
|
526
|
+
def decrypt_password_if_encrypted(cipher_text: str) -> str:
|
|
527
|
+
"""解密前端加密上传的密码(支持 RSA 或 AES-GCM),否则原样返回。
|
|
528
|
+
|
|
529
|
+
配置:
|
|
530
|
+
- LOGIN_PASSWORD_ENCRYPTION=rsa|aes|none(默认 none)
|
|
531
|
+
- LOGIN_PASSWORD_REQUIRE_ENCRYPTION=true|false(默认 false;true 时解密失败直接拒绝登录)
|
|
532
|
+
- RSA:AUTH_RSA_PRIVATE_KEY_FILE / AUTH_RSA_PRIVATE_KEY / AUTH_RSA_PRIVATE_KEY_PASSPHRASE
|
|
533
|
+
- AES-GCM:LOGIN_PASSWORD_SECRET_KEY(Base64,16/24/32 字节)
|
|
534
|
+
输入:
|
|
535
|
+
- cipher_text:密码字符串;若以 "rsa:" 或 "aes:" 前缀或配置指定模式,则尝试解密
|
|
536
|
+
返回:明文密码字符串;严格模式下失败抛出 KCAUTH_LOGIN_FAILED。
|
|
537
|
+
"""
|
|
538
|
+
if not isinstance(cipher_text, str):
|
|
539
|
+
return cipher_text
|
|
540
|
+
enc_mode = os.getenv("LOGIN_PASSWORD_ENCRYPTION", "none").lower()
|
|
541
|
+
require_enc = os.getenv("LOGIN_PASSWORD_REQUIRE_ENCRYPTION", "false").lower() in ("1", "true", "yes", "on")
|
|
542
|
+
force_rsa = enc_mode == "rsa" or cipher_text.startswith("rsa:") or cipher_text.startswith("RSA:")
|
|
543
|
+
force_aes = enc_mode == "aes" or cipher_text.startswith("aes:") or cipher_text.startswith("AES:")
|
|
544
|
+
|
|
545
|
+
# AES-GCM 分支(共享密钥)
|
|
546
|
+
if force_aes:
|
|
547
|
+
# 去除前缀
|
|
548
|
+
remain = cipher_text.split(":", 1)[1] if cipher_text.lower().startswith("aes:") else cipher_text
|
|
549
|
+
# 支持两种格式:
|
|
550
|
+
# 1) 聚合 Base64:base64(nonce(12) + ciphertext + tag(16))
|
|
551
|
+
# 2) 分段:nonce_b64:cipher_b64:tag_b64
|
|
552
|
+
nonce = ct = tag = None
|
|
553
|
+
try:
|
|
554
|
+
if ":" in remain:
|
|
555
|
+
parts = remain.split(":")
|
|
556
|
+
if len(parts) != 3:
|
|
557
|
+
raise ValueError("AES 密文格式错误,期望 3 段:nonce:cipher:tag")
|
|
558
|
+
nonce = base64.b64decode(parts[0])
|
|
559
|
+
ct = base64.b64decode(parts[1])
|
|
560
|
+
tag = base64.b64decode(parts[2])
|
|
561
|
+
else:
|
|
562
|
+
agg = base64.b64decode(remain)
|
|
563
|
+
if len(agg) < 12 + 16:
|
|
564
|
+
raise ValueError("AES 聚合密文过短")
|
|
565
|
+
nonce = agg[:12]
|
|
566
|
+
tag = agg[-16:]
|
|
567
|
+
ct = agg[12:-16]
|
|
568
|
+
except Exception as e:
|
|
569
|
+
if require_enc:
|
|
570
|
+
raise KCAUTH_LOGIN_FAILED.msg_format("AES 密文解析失败") from e
|
|
571
|
+
logger.warning(f"AES 密文解析失败: {e}")
|
|
572
|
+
return cipher_text
|
|
573
|
+
|
|
574
|
+
key = KairoAuth._load_aes_secret_key()
|
|
575
|
+
if key is None:
|
|
576
|
+
if require_enc:
|
|
577
|
+
raise KCAUTH_LOGIN_FAILED.msg_format("未配置 AES 密钥,无法解密密码")
|
|
578
|
+
logger.warning("未配置 AES 密钥,无法解密密码,回退原文")
|
|
579
|
+
return cipher_text
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
decryptor = Cipher(algorithms.AES(key), modes.GCM(nonce, tag)).decryptor()
|
|
583
|
+
plain_bytes = decryptor.update(ct) + decryptor.finalize()
|
|
584
|
+
return plain_bytes.decode("utf-8", errors="strict")
|
|
585
|
+
except Exception as e:
|
|
586
|
+
if require_enc:
|
|
587
|
+
raise KCAUTH_LOGIN_FAILED.msg_format("AES 密码解密失败") from e
|
|
588
|
+
logger.warning(f"AES 密码解密失败: {e}")
|
|
589
|
+
return cipher_text
|
|
590
|
+
|
|
591
|
+
# RSA-OAEP 分支(公私钥)
|
|
592
|
+
if force_rsa:
|
|
593
|
+
# 去除可选前缀
|
|
594
|
+
if cipher_text.lower().startswith("rsa:"):
|
|
595
|
+
cipher_b64 = cipher_text.split(":", 1)[1]
|
|
596
|
+
else:
|
|
597
|
+
cipher_b64 = cipher_text
|
|
598
|
+
private_key = KairoAuth._load_rsa_private_key()
|
|
599
|
+
if private_key is None:
|
|
600
|
+
if require_enc:
|
|
601
|
+
raise KCAUTH_LOGIN_FAILED.msg_format("未配置 RSA 私钥,无法解密密码")
|
|
602
|
+
logger.warning("未配置 RSA 私钥,无法解密密码,回退为原文")
|
|
603
|
+
return cipher_text
|
|
604
|
+
try:
|
|
605
|
+
try:
|
|
606
|
+
cipher_bytes = base64.b64decode(cipher_b64)
|
|
607
|
+
except Exception:
|
|
608
|
+
padding_len = (-len(cipher_b64)) % 4
|
|
609
|
+
cipher_bytes = base64.urlsafe_b64decode(cipher_b64 + ("=" * padding_len))
|
|
610
|
+
plain_bytes = private_key.decrypt(
|
|
611
|
+
cipher_bytes,
|
|
612
|
+
padding.OAEP(
|
|
613
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
614
|
+
algorithm=hashes.SHA256(),
|
|
615
|
+
label=None,
|
|
616
|
+
),
|
|
617
|
+
)
|
|
618
|
+
return plain_bytes.decode("utf-8", errors="strict")
|
|
619
|
+
except Exception as e:
|
|
620
|
+
if require_enc:
|
|
621
|
+
raise KCAUTH_LOGIN_FAILED.msg_format("RSA 密码解密失败") from e
|
|
622
|
+
logger.warning(f"RSA 密码解密失败: {e}")
|
|
623
|
+
return cipher_text
|
|
624
|
+
|
|
625
|
+
# 未触发任何加密模式
|
|
626
|
+
if require_enc:
|
|
627
|
+
raise KCAUTH_LOGIN_FAILED.msg_format("必须使用加密密码上传")
|
|
628
|
+
return cipher_text
|
|
629
|
+
|