Spaces:
Running
Running
sync from GitHub dad1933: fix(认证+恢复): 手表端长效token+refresh不吊销旧token+恢复容错
Browse filesAuto-synced from GitHub commit dad193322c9efba04d7d142428d87acc80931a08
- app/api/user_files.py +34 -14
app/api/user_files.py
CHANGED
|
@@ -66,6 +66,19 @@ def _token_ttl_sec() -> int:
|
|
| 66 |
return get_settings().xtc_user_token_ttl_sec
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def _user_token_jti_from_raw(raw_token: str) -> str:
|
| 70 |
"""从原始 JWT 中无验签地提取 jti(用于吊销)。
|
| 71 |
|
|
@@ -118,11 +131,12 @@ def _resolve_alias(row_or_meta) -> str:
|
|
| 118 |
|
| 119 |
# ===== JWT =====
|
| 120 |
|
| 121 |
-
def _issue_user_token(user_id: int, username: str) -> str:
|
| 122 |
"""签发用户 JWT(NM2 / NL2)。
|
| 123 |
|
| 124 |
- 加入 jti(唯一 ID)并落 user_tokens 表,支持服务端吊销
|
| 125 |
- TTL 从 settings.xtc_user_token_ttl_sec 读取(默认 1 天,原 7 天)
|
|
|
|
| 126 |
- 若配置了 XTC_JWT_KID 则写入 header 便于密钥轮换识别
|
| 127 |
"""
|
| 128 |
settings = get_settings()
|
|
@@ -133,7 +147,7 @@ def _issue_user_token(user_id: int, username: str) -> str:
|
|
| 133 |
code="server_misconfigured",
|
| 134 |
)
|
| 135 |
now = int(time.time())
|
| 136 |
-
ttl = _token_ttl_sec()
|
| 137 |
jti = uuid.uuid4().hex
|
| 138 |
payload = {
|
| 139 |
"type": "user_file",
|
|
@@ -143,6 +157,8 @@ def _issue_user_token(user_id: int, username: str) -> str:
|
|
| 143 |
"exp": now + ttl,
|
| 144 |
"jti": jti,
|
| 145 |
}
|
|
|
|
|
|
|
| 146 |
headers: dict = {}
|
| 147 |
if settings.xtc_jwt_kid:
|
| 148 |
headers["kid"] = settings.xtc_jwt_kid
|
|
@@ -484,7 +500,7 @@ async def register(request: Request):
|
|
| 484 |
client_ip = rate_limit_store.get_client_ip(request)
|
| 485 |
rate_limit_store.clear_auth_failures(client_ip=client_ip)
|
| 486 |
|
| 487 |
-
token = _issue_user_token(user_id, username)
|
| 488 |
return ok_with_cors({
|
| 489 |
"token": token,
|
| 490 |
"user": {"id": user_id, "username": username},
|
|
@@ -518,7 +534,7 @@ async def login(request: Request):
|
|
| 518 |
client_ip = rate_limit_store.get_client_ip(request)
|
| 519 |
rate_limit_store.clear_auth_failures(client_ip=client_ip)
|
| 520 |
|
| 521 |
-
token = _issue_user_token(int(user["id"]), user["username"])
|
| 522 |
return ok_with_cors({
|
| 523 |
"token": token,
|
| 524 |
"user": {"id": int(user["id"]), "username": user["username"]},
|
|
@@ -620,14 +636,18 @@ def _verify_user_token_for_refresh(token: str) -> dict:
|
|
| 620 |
return payload
|
| 621 |
|
| 622 |
|
| 623 |
-
def _do_refresh_token(user: dict):
|
| 624 |
user_id = int(user["sub"])
|
| 625 |
username = user["username"]
|
| 626 |
-
#
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
return ok_with_cors({
|
| 632 |
"token": token,
|
| 633 |
"user": {"id": user_id, "username": username},
|
|
@@ -635,13 +655,13 @@ def _do_refresh_token(user: dict):
|
|
| 635 |
|
| 636 |
|
| 637 |
@router.post("/refresh")
|
| 638 |
-
async def refresh_token(user: dict = Depends(require_user_for_refresh)):
|
| 639 |
-
return _do_refresh_token(user)
|
| 640 |
|
| 641 |
|
| 642 |
@router.get("/refresh")
|
| 643 |
-
async def refresh_token_get(user: dict = Depends(require_user_for_refresh)):
|
| 644 |
-
return _do_refresh_token(user)
|
| 645 |
|
| 646 |
|
| 647 |
# ===== 列表 =====
|
|
|
|
| 66 |
return get_settings().xtc_user_token_ttl_sec
|
| 67 |
|
| 68 |
|
| 69 |
+
# 手表端长效 token TTL:3 天。
|
| 70 |
+
# 手表端(UA 含 "xtc")备份耗时长、并发请求多,1 天 TTL + rotation 机制容易导致
|
| 71 |
+
# 备份中途 401(refresh 吊销旧 token,其他并发请求仍在用旧 token)。
|
| 72 |
+
# 3 天 TTL 大幅减少 refresh 频率,从根本上避免 rotation 死锁。
|
| 73 |
+
_XTC_LONG_TOKEN_TTL_SEC = 3 * 86400
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _is_xtc_client(request: Request) -> bool:
|
| 77 |
+
"""检测请求是否来自小天才手表端(UA 含 "xtc")。"""
|
| 78 |
+
ua = (request.headers.get("user-agent") or "").lower()
|
| 79 |
+
return "xtc" in ua
|
| 80 |
+
|
| 81 |
+
|
| 82 |
def _user_token_jti_from_raw(raw_token: str) -> str:
|
| 83 |
"""从原始 JWT 中无验签地提取 jti(用于吊销)。
|
| 84 |
|
|
|
|
| 131 |
|
| 132 |
# ===== JWT =====
|
| 133 |
|
| 134 |
+
def _issue_user_token(user_id: int, username: str, long_ttl: bool = False) -> str:
|
| 135 |
"""签发用户 JWT(NM2 / NL2)。
|
| 136 |
|
| 137 |
- 加入 jti(唯一 ID)并落 user_tokens 表,支持服务端吊销
|
| 138 |
- TTL 从 settings.xtc_user_token_ttl_sec 读取(默认 1 天,原 7 天)
|
| 139 |
+
- long_ttl=True 时签发 3 天长效 token(手表端专用,减少 refresh 频率)
|
| 140 |
- 若配置了 XTC_JWT_KID 则写入 header 便于密钥轮换识别
|
| 141 |
"""
|
| 142 |
settings = get_settings()
|
|
|
|
| 147 |
code="server_misconfigured",
|
| 148 |
)
|
| 149 |
now = int(time.time())
|
| 150 |
+
ttl = _XTC_LONG_TOKEN_TTL_SEC if long_ttl else _token_ttl_sec()
|
| 151 |
jti = uuid.uuid4().hex
|
| 152 |
payload = {
|
| 153 |
"type": "user_file",
|
|
|
|
| 157 |
"exp": now + ttl,
|
| 158 |
"jti": jti,
|
| 159 |
}
|
| 160 |
+
if long_ttl:
|
| 161 |
+
payload["long_ttl"] = True
|
| 162 |
headers: dict = {}
|
| 163 |
if settings.xtc_jwt_kid:
|
| 164 |
headers["kid"] = settings.xtc_jwt_kid
|
|
|
|
| 500 |
client_ip = rate_limit_store.get_client_ip(request)
|
| 501 |
rate_limit_store.clear_auth_failures(client_ip=client_ip)
|
| 502 |
|
| 503 |
+
token = _issue_user_token(user_id, username, long_ttl=_is_xtc_client(request))
|
| 504 |
return ok_with_cors({
|
| 505 |
"token": token,
|
| 506 |
"user": {"id": user_id, "username": username},
|
|
|
|
| 534 |
client_ip = rate_limit_store.get_client_ip(request)
|
| 535 |
rate_limit_store.clear_auth_failures(client_ip=client_ip)
|
| 536 |
|
| 537 |
+
token = _issue_user_token(int(user["id"]), user["username"], long_ttl=_is_xtc_client(request))
|
| 538 |
return ok_with_cors({
|
| 539 |
"token": token,
|
| 540 |
"user": {"id": int(user["id"]), "username": user["username"]},
|
|
|
|
| 636 |
return payload
|
| 637 |
|
| 638 |
|
| 639 |
+
def _do_refresh_token(user: dict, request: Request):
|
| 640 |
user_id = int(user["sub"])
|
| 641 |
username = user["username"]
|
| 642 |
+
# NL7:不再吊销旧 token(rotation 改为共存)。
|
| 643 |
+
# 旧实现 refresh 时吊销旧 jti,但备份中其他并发请求可能仍持有旧 token,
|
| 644 |
+
# 5 分钟宽限期边界场景会导致 401 死锁。改为新旧 token 共存,旧 token 自然过期。
|
| 645 |
+
# 代价是多占几行 user_tokens 记录,由 _cleanup_expired_user_tokens 清理。
|
| 646 |
+
long_ttl = _is_xtc_client(request)
|
| 647 |
+
# 若旧 token 是长效的,新 token 也保持长效
|
| 648 |
+
if not long_ttl and user.get("long_ttl"):
|
| 649 |
+
long_ttl = True
|
| 650 |
+
token = _issue_user_token(user_id, username, long_ttl=long_ttl)
|
| 651 |
return ok_with_cors({
|
| 652 |
"token": token,
|
| 653 |
"user": {"id": user_id, "username": username},
|
|
|
|
| 655 |
|
| 656 |
|
| 657 |
@router.post("/refresh")
|
| 658 |
+
async def refresh_token(request: Request, user: dict = Depends(require_user_for_refresh)):
|
| 659 |
+
return _do_refresh_token(user, request)
|
| 660 |
|
| 661 |
|
| 662 |
@router.get("/refresh")
|
| 663 |
+
async def refresh_token_get(request: Request, user: dict = Depends(require_user_for_refresh)):
|
| 664 |
+
return _do_refresh_token(user, request)
|
| 665 |
|
| 666 |
|
| 667 |
# ===== 列表 =====
|