Skip to content

Commit ed5c55a

Browse files
committed
fix(gateway): JWT 算法自动适配消除 HS256/ES256 错位 401,学习计划认证就绪前不发请求
1 parent 4990849 commit ed5c55a

5 files changed

Lines changed: 155 additions & 47 deletions

File tree

‎client/src/features/planner/hooks/useLearningPlan.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* @ai-context: Daily plan hook: loads today's plan, generates via AI when
99
* missing, falls back to local rule planning on AI failure (offline-first).
1010
*/
11-
import { useCallback, useEffect, useState } from 'react';
11+
import { useCallback, useEffect, useRef, useState } from 'react';
1212
import { useAuth } from '@/lib/auth/AuthContext';
1313
import {
1414
buildLocalPlan, buildPlanContextText, loadPlan, planFromAI,
@@ -26,27 +26,33 @@ export interface UseLearningPlanReturn {
2626
}
2727

2828
export function useLearningPlan(): UseLearningPlanReturn {
29-
const { session } = useAuth();
29+
const { session, loading: authLoading } = useAuth();
3030
const [plan, setPlan] = useState<LearningPlan | null>(null);
3131
const [loading, setLoading] = useState(true);
32+
// 防重入:session 恢复过程中 generate 重建会重复触发 useEffect,
33+
// 并发发出无 token 与带 token 两个请求(前者必 401 且浪费网关流量)
34+
const generatingRef = useRef(false);
3235

3336
/** 生成计划:AI 优先,本地规则兜底 */
3437
const generate = useCallback(async (): Promise<void> => {
38+
if (generatingRef.current) return;
39+
generatingRef.current = true;
3540
setLoading(true);
3641
try {
3742
const ctx = await buildPlanContextText();
3843
let next: LearningPlan | null = null;
3944

40-
// AI 路径(Electron 可用时;失败/空结果静默回退)
41-
if (window.electronAPI) {
45+
// AI 路径:仅已登录且 Electron 可用时调用(未登录时网关必 401,
46+
// 本地优先原则——无 token 直接走本地规则规划,不发无意义请求)
47+
if (window.electronAPI && session?.access_token) {
4248
try {
4349
const resp = await window.electronAPI.invoke('ai_learning_plan', {
4450
masterySummary: ctx.masterySummary,
4551
dueCounts: ctx.dueCounts,
4652
peakHours: ctx.peakHours,
4753
weeklyGoalMinutes: ctx.weeklyGoalMinutes,
4854
todayMinutes: ctx.todayMinutes,
49-
authToken: session?.access_token ?? null,
55+
authToken: session.access_token,
5056
}) as {
5157
date?: string;
5258
items?: Array<{ module: string; title?: string; minutes?: number; task?: string; reason?: string; order?: number }>;
@@ -76,19 +82,23 @@ export function useLearningPlan(): UseLearningPlanReturn {
7682
setPlan(null);
7783
}
7884
} finally {
85+
generatingRef.current = false;
7986
setLoading(false);
8087
}
8188
}, [session?.access_token]);
8289

8390
useEffect(() => {
91+
// 等待 auth 就绪:session 恢复前不触发生成(避免无 token 请求 401
92+
// 与 session 变化导致的重复并发调用)
93+
if (authLoading) return;
8494
const existing = loadPlan(todayISO());
8595
if (existing) {
8696
setPlan(existing);
8797
setLoading(false);
8898
} else {
8999
void generate();
90100
}
91-
}, [generate]);
101+
}, [generate, authLoading]);
92102

93103
const regenerate = useCallback(async () => {
94104
await generate();

‎server/ai-gateway/config/app.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
@ai-context: 汇聚运行环境、CORS、JWT 与中间件连接串等应用级配置。
55
Supabase JWKS 端点从 SUPABASE_URL 推导(标准路径含 /auth/v1 前缀),
66
亦可经 SUPABASE_JWKS_URL 显式覆盖。所有值均经环境变量注入,无硬编码密钥。
7-
@ai-context: GW-2#1——jwt_algorithm 由 SUPABASE_JWT_ALGORITHM 注入,默认 HS256
8-
(Supabase 默认对称密钥签发机制);ES256 仅在开启自定义 JWT 时选用。
7+
@ai-context: GW-2#1——jwt_algorithm 由 SUPABASE_JWT_ALGORITHM 注入,
8+
空值=自动适配模式(按 token 实际算法 HS256/ES256/RS256 选用密钥材料),
9+
显式配置则强制该算法;ES256 仅在开启自定义 JWT 时选用。
910
"""
1011

1112
import os
@@ -27,10 +28,11 @@
2728
if origin.strip()
2829
],
2930
"jwt_secret": os.getenv("SUPABASE_JWT_SECRET", ""),
30-
# GW-2#1: 算法由环境变量注入(默认 HS256 与 Supabase 默认签发机制对齐);
31-
# 原硬编码 ES256 导致默认 HS256 项目(无 JWKS 端点)全站 401
31+
# GW-2#1: 算法由环境变量注入;空值=自动适配模式(按 token 实际算法选择密钥
32+
# 材料,从根上消除 HS256/ES256 配置错位导致的全站 401——原硬编码 ES256 使
33+
# 默认 HS256 项目全站 401,默认 HS256 又会使自定义 JWT(ES256)项目全站 401)
3234
# GW-3: strip().upper() 规范化——小写 hs256/es256 配置也能正确匹配
33-
"jwt_algorithm": os.getenv("SUPABASE_JWT_ALGORITHM", "HS256").strip().upper(),
35+
"jwt_algorithm": os.getenv("SUPABASE_JWT_ALGORITHM", "").strip().upper(),
3436
"supabase_url": _supabase_url,
3537
"supabase_jwks_url": os.getenv(
3638
"SUPABASE_JWKS_URL",

‎server/ai-gateway/middleware/auth.py‎

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
- ES256:使用 ECDSA P-256 公钥验证,公钥从 Supabase JWKS 端点获取(按 kid 匹配)
1010
- RS256:使用 RSA 公钥(PEM)验证
1111
12+
未配置 SUPABASE_JWT_ALGORITHM 时按 token 实际算法自动适配(GW-2#1 延伸:
13+
从根上消除 HS256/ES256 配置错位导致的全站 401),显式配置则强制白名单。
14+
1215
@ai-context: JWT 认证中间件:校验 Supabase JWT(ES256 经 JWKS,或 HS256/RS256 经密钥),未配置时以占位密钥放行供本地开发。
1316
"""
1417

@@ -53,20 +56,32 @@ def _jwt_verification_configured() -> bool:
5356
"""
5457
检查当前算法是否具备验证所需的密钥材料。
5558
56-
- HS256/RS256:需要 SUPABASE_JWT_SECRET(对称密钥或 PEM 公钥),
59+
- 显式配置 HS256/RS256:需要 SUPABASE_JWT_SECRET(对称密钥或 PEM 公钥),
5760
占位符/示例值视为未配置;
58-
- ES256:需要 SUPABASE_JWKS_URL 或 SUPABASE_URL(用于获取 JWKS 公钥),
59-
URL 含 your-project-id 占位符域名时视为未配置。
61+
- 显式配置 ES256:需要 SUPABASE_JWKS_URL 或 SUPABASE_URL(用于获取 JWKS 公钥),
62+
URL 含 your-project-id 占位符域名时视为未配置;
63+
- 未配置算法(自动适配模式):任一算法的密钥材料可用即视为已配置,
64+
验证时按 token 实际算法选用对应材料(GW-2#1 延伸)。
6065
"""
61-
alg = APP_CONFIG.get("jwt_algorithm", "HS256")
66+
alg = APP_CONFIG.get("jwt_algorithm", "").strip().upper()
6267
if alg == "ES256":
6368
jwks_url = (
6469
APP_CONFIG.get("supabase_jwks_url", "")
6570
or APP_CONFIG.get("supabase_url", "")
6671
)
6772
return bool(jwks_url) and _PLACEHOLDER_PROJECT_ID not in jwks_url
73+
if alg:
74+
secret = APP_CONFIG.get("jwt_secret", "")
75+
return bool(secret) and not _is_placeholder_text(secret)
76+
# 自动适配模式:任一算法密钥材料可用即真实验证(fail-closed,不降级)
6877
secret = APP_CONFIG.get("jwt_secret", "")
69-
return bool(secret) and not _is_placeholder_text(secret)
78+
jwks_url = (
79+
APP_CONFIG.get("supabase_jwks_url", "")
80+
or APP_CONFIG.get("supabase_url", "")
81+
)
82+
has_secret = bool(secret) and not _is_placeholder_text(secret)
83+
has_jwks = bool(jwks_url) and _PLACEHOLDER_PROJECT_ID not in jwks_url
84+
return has_secret or has_jwks
7085

7186

7287
# 启动时检查密钥配置
@@ -104,7 +119,7 @@ def _jwt_verification_configured() -> bool:
104119
# 启动日志:输当前 JWT 验证算法与配置状态(GW-2#1: 避免"已配置"假象)
105120
logger.info(
106121
"JWT 验证算法: %s (验证材料已配置=%s)",
107-
APP_CONFIG["jwt_algorithm"],
122+
APP_CONFIG["jwt_algorithm"] or "auto",
108123
_jwt_verification_configured(),
109124
)
110125

@@ -324,18 +339,17 @@ async def _get_es256_public_key(kid: Optional[str] = None):
324339
raise ValueError(f"JWKS 中未找到 kid={kid} 对应的 EC P-256 公钥")
325340

326341

327-
async def _get_public_key(kid: Optional[str] = None):
342+
async def _get_public_key(alg: str, kid: Optional[str] = None):
328343
"""
329-
获取用于 JWT 验证的密钥。
344+
按 token 实际签名算法获取用于 JWT 验证的密钥。
330345
331346
- HS256:返回对称密钥字符串(SUPABASE_JWT_SECRET)
332347
- ES256:从 JWKS 端点获取 ECDSA P-256 公钥(按 kid 匹配)
333348
- RS256:返回 RSA 公钥 PEM 格式
334349
"""
335-
alg = APP_CONFIG.get("jwt_algorithm", "HS256")
336350
if alg == "ES256":
337351
return await _get_es256_public_key(kid)
338-
raw = APP_CONFIG["jwt_secret"]
352+
raw = APP_CONFIG.get("jwt_secret", "")
339353
if not raw:
340354
# 未配置时返回占位符,jose 会在验证时报错(优雅降级)
341355
return "not-configured"
@@ -443,29 +457,36 @@ async def _verify_token(self, request: Request) -> str:
443457
# 使用 python-jose 解码并验证 JWT token
444458
from jose import jwt, JWTError, ExpiredSignatureError
445459

446-
alg = APP_CONFIG.get("jwt_algorithm", "HS256")
447-
448-
# ES256 需要从 token header 提取 kid,以匹配 JWKS 中的公钥
460+
# 解析 token header:获取实际签名算法与 kid(ES256 按 kid 匹配 JWKS 公钥)
461+
token_alg = None
449462
kid = None
450-
if alg == "ES256":
451-
try:
452-
import json
453-
header_b64 = token.split('.')[0]
454-
header_b64 += '=' * (-len(header_b64) % 4)
455-
token_header = json.loads(base64.urlsafe_b64decode(header_b64))
456-
kid = token_header.get("kid")
457-
except Exception as e:
458-
logger.warning("解析 token header 获取 kid 失败: %s", str(e))
463+
try:
464+
import json
465+
header_b64 = token.split('.')[0]
466+
header_b64 += '=' * (-len(header_b64) % 4)
467+
token_header = json.loads(base64.urlsafe_b64decode(header_b64))
468+
token_alg = (token_header.get("alg") or "").strip().upper()
469+
kid = token_header.get("kid")
470+
except Exception as e:
471+
logger.warning("解析 token header 获取 alg/kid 失败: %s", str(e))
472+
473+
# 算法白名单:显式配置 SUPABASE_JWT_ALGORITHM 时强制该算法;
474+
# 未配置时自动适配(GW-2#1 延伸——不猜默认算法,按 token 实际 alg 验证,
475+
# 消除 HS256/ES256 配置错位导致的全站 401)
476+
configured_alg = APP_CONFIG.get("jwt_algorithm", "").strip().upper()
477+
allowed_algs = [configured_alg] if configured_alg else ["HS256", "ES256", "RS256"]
478+
if not token_alg or token_alg not in allowed_algs:
479+
raise AuthenticationError(f"token 验证失败: 不支持的签名算法 {token_alg or '未知'}")
459480

460481
try:
461-
public_key = await _get_public_key(kid=kid)
482+
public_key = await _get_public_key(token_alg, kid=kid)
462483
except Exception as e:
463484
logger.error("获取公钥失败: %s", str(e))
464485
raise AuthenticationError("认证服务异常,请稍后重试") from e
465486

466487
# 构建解码参数
467488
decode_kwargs: dict = {
468-
"algorithms": [alg],
489+
"algorithms": [token_alg],
469490
"audience": "authenticated", # Supabase JWT 的标准 audience
470491
}
471492
# 当配置了 supabase_url 时,验证 iss claim

‎server/ai-gateway/tests/test_config.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,11 @@ def test_evaluate_returns_glm_model(self):
255255
class TestConfigAssertions:
256256
"""配置值精确断言(JWT 算法、API Key 校验、超时配置、Fallback 链尾)"""
257257

258-
def test_jwt_algorithm_defaults_to_hs256(self):
259-
"""APP_CONFIG jwt_algorithm 默认 HS256(GW-2#1: 与 Supabase 默认对称密钥
260-
签发机制对齐;原硬编码 ES256 导致无 JWKS 的默认项目全站 401)。
261-
显式配置 SUPABASE_JWT_ALGORITHM 时使用配置值。"""
262-
expected = os.getenv("SUPABASE_JWT_ALGORITHM", "HS256")
258+
def test_jwt_algorithm_defaults_to_auto(self):
259+
"""APP_CONFIG jwt_algorithm 默认空串=自动适配模式(GW-2#1 延伸:不猜默认
260+
算法,按 token 实际 alg 选择密钥材料,消除 HS256/ES256 配置错位导致的
261+
全站 401)。显式配置 SUPABASE_JWT_ALGORITHM 时使用配置值。"""
262+
expected = os.getenv("SUPABASE_JWT_ALGORITHM", "").strip().upper()
263263
assert APP_CONFIG["jwt_algorithm"] == expected
264264

265265
def test_is_valid_api_key_rejects_placeholder(self):

‎server/ai-gateway/tests/test_e2e_auth.py‎

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ def client():
5757
return TestClient(_make_test_app())
5858

5959

60-
def _make_fake_jwt_token(sub: str = "test-user") -> str:
61-
"""构造一个不验签的假 JWT token(仅用于 dev 模式解码测试)"""
62-
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).decode().rstrip("=")
60+
def _make_fake_jwt_token(sub: str = "test-user", alg: str = "HS256") -> str:
61+
"""构造一个不验签的假 JWT token(header 含指定 alg,便于算法白名单解析)"""
62+
header = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).decode().rstrip("=")
6363
payload = base64.urlsafe_b64encode(json.dumps({"sub": sub}).encode()).decode().rstrip("=")
6464
signature = "fake-signature"
6565
return f"{header}.{payload}.{signature}"
@@ -82,10 +82,11 @@ def _set_secret(self, monkeypatch):
8282

8383
def test_valid_bearer_token_decodes_user_id(self, client):
8484
"""有有效 Bearer token 时应正确解码并返回 user_id"""
85+
fake_token = _make_fake_jwt_token()
8586
with patch("jose.jwt.decode", return_value={"sub": "test-user"}):
8687
response = client.get(
8788
"/api/v1/ai/test",
88-
headers={"Authorization": "Bearer fake.jwt.token"},
89+
headers={"Authorization": f"Bearer {fake_token}"},
8990
)
9091
assert response.status_code == 200
9192
assert response.json() == {"ok": True}
@@ -118,10 +119,11 @@ def test_expired_token_returns_401(self, client):
118119
"""token 过期时应返回 401"""
119120
from jose import ExpiredSignatureError
120121

122+
fake_token = _make_fake_jwt_token()
121123
with patch("jose.jwt.decode", side_effect=ExpiredSignatureError("Token expired")):
122124
response = client.get(
123125
"/api/v1/ai/test",
124-
headers={"Authorization": "Bearer expired.jwt.token"},
126+
headers={"Authorization": f"Bearer {fake_token}"},
125127
)
126128
assert response.status_code == 401
127129
assert "过期" in response.json()["detail"]
@@ -130,25 +132,98 @@ def test_invalid_signature_returns_401(self, client):
130132
"""token 签名验证失败时应返回 401"""
131133
from jose import JWTError
132134

135+
fake_token = _make_fake_jwt_token()
133136
with patch("jose.jwt.decode", side_effect=JWTError("Signature verification failed")):
134137
response = client.get(
135138
"/api/v1/ai/test",
136-
headers={"Authorization": "Bearer invalid.jwt.signature"},
139+
headers={"Authorization": f"Bearer {fake_token}"},
137140
)
138141
assert response.status_code == 401
139142
assert "验证失败" in response.json()["detail"]
140143

141144
def test_token_missing_sub_claim_returns_401(self, client):
142145
"""token 中缺少 sub claim 时应返回 401"""
146+
fake_token = _make_fake_jwt_token()
143147
with patch("jose.jwt.decode", return_value={"exp": 9999999999}):
144148
response = client.get(
145149
"/api/v1/ai/test",
146-
headers={"Authorization": "Bearer valid.but.no-sub.token"},
150+
headers={"Authorization": f"Bearer {fake_token}"},
147151
)
148152
assert response.status_code == 401
149153
assert "sub" in response.json()["detail"] or "用户标识" in response.json()["detail"]
150154

151155

156+
# ────────────────────────────────────────────────────────────
157+
# 自动适配模式(jwt_algorithm 未配置)
158+
# ────────────────────────────────────────────────────────────
159+
160+
161+
class TestJWTAutoMode:
162+
"""SUPABASE_JWT_ALGORITHM 未配置时按 token 实际算法自动适配验证
163+
164+
GW-2#1 延伸:不猜默认算法(原硬编码 ES256 / 默认 HS256 都曾使另一
165+
算法的项目全站 401),按 token header 的 alg 选用对应密钥材料。
166+
"""
167+
168+
@pytest.fixture(autouse=True)
169+
def _set_auto_mode(self, monkeypatch):
170+
"""清空算法配置但保留对称密钥 —— 自动适配 + HS256 材料就绪"""
171+
monkeypatch.setitem(APP_CONFIG, "jwt_secret", "test-secret-key")
172+
monkeypatch.setitem(APP_CONFIG, "jwt_algorithm", "")
173+
monkeypatch.setitem(APP_CONFIG, "supabase_url", "")
174+
175+
def test_hs256_token_decodes_with_secret(self, client):
176+
"""自动模式下 HS256 token 应使用对称密钥验证通过"""
177+
fake_token = _make_fake_jwt_token(alg="HS256")
178+
with patch("jose.jwt.decode", return_value={"sub": "test-user"}):
179+
response = client.get(
180+
"/api/v1/ai/test",
181+
headers={"Authorization": f"Bearer {fake_token}"},
182+
)
183+
assert response.status_code == 200
184+
assert response.json() == {"ok": True}
185+
186+
def test_es256_token_decodes_with_jwks(self, client):
187+
"""自动模式下 ES256 token 应走 JWKS 公钥验证(按 kid 匹配)"""
188+
from unittest.mock import AsyncMock
189+
190+
fake_token = _make_fake_jwt_token(alg="ES256")
191+
with patch(
192+
"middleware.auth._get_es256_public_key",
193+
new=AsyncMock(return_value="es256-public-key"),
194+
), patch("jose.jwt.decode", return_value={"sub": "test-user"}):
195+
response = client.get(
196+
"/api/v1/ai/test",
197+
headers={"Authorization": f"Bearer {fake_token}"},
198+
)
199+
assert response.status_code == 200
200+
assert response.json() == {"ok": True}
201+
202+
def test_unsupported_alg_returns_401(self, client):
203+
"""自动模式下白名单之外的算法(如 none)应 401"""
204+
fake_token = _make_fake_jwt_token(alg="none")
205+
response = client.get(
206+
"/api/v1/ai/test",
207+
headers={"Authorization": f"Bearer {fake_token}"},
208+
)
209+
assert response.status_code == 401
210+
assert "不支持的签名算法" in response.json()["detail"]
211+
212+
def test_garbage_token_returns_401(self, client):
213+
"""自动模式下无法解析算法头的 token 应 401(fail-closed)"""
214+
response = client.get(
215+
"/api/v1/ai/test",
216+
headers={"Authorization": "Bearer not-a-jwt"},
217+
)
218+
assert response.status_code == 401
219+
220+
def test_no_authorization_header_returns_401(self, client):
221+
"""自动模式下无 Authorization 头仍应 401(fail-closed,不降级放行)"""
222+
response = client.get("/api/v1/ai/test")
223+
assert response.status_code == 401
224+
assert "Authorization" in response.json()["detail"]
225+
226+
152227
# ────────────────────────────────────────────────────────────
153228
# 开发降级模式(jwt_secret 为空)
154229
# ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)