From 45e07d44e49605439165a33f52165de5b533c11d Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 27 Sep 2026 22:50:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(auth):=20OAuth=20=E5=9B=9E=E8=B0=83=20f?= =?UTF-8?q?ragment=20=E5=8E=BB=E5=87=AD=E8=AF=81=E5=8C=96=E4=BB=85?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=9D=9E=E6=95=8F=E6=84=9F=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/oauth.py | 12 ++++---- backend/tests_oauth_patch/test_oauth_fix.py | 16 ++++++++-- frontend/src/app/oauth/callback/page.tsx | 33 ++++++++------------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/backend/app/api/v1/oauth.py b/backend/app/api/v1/oauth.py index 3b2c6645..5546d9cc 100644 --- a/backend/app/api/v1/oauth.py +++ b/backend/app/api/v1/oauth.py @@ -4,7 +4,8 @@ 1. 前端整页跳转 GET /auth/oauth/{provider}/login → 后端 302 到 provider 授权页 2. provider 回调 GET /auth/oauth/{provider}/callback → 换 token + 拉 userinfo 3. 解析为本地 User(自动合并同邮箱账号)+ 建 session - 4. 302 到前端回调页,token 走 URL fragment(不进 server log / Referer) + 4. 302 到前端回调页;会话凭证只经 HttpOnly cookie 下发, + fragment 仅携带非敏感状态(provider / expires_at,见 #63) """ from __future__ import annotations @@ -66,7 +67,7 @@ def _backend_callback_url(request: Request, provider: str) -> str: def _frontend_redirect(fragment: str | None = None, error: str | None = None) -> RedirectResponse: - """构造跳回前端的响应。token 走 fragment,错误走 query。""" + """构造跳回前端的响应。非敏感状态走 fragment,错误走 query(#63:fragment 不含凭证)。""" target = settings.OAUTH_FRONTEND_REDIRECT_URL if not target: raise HTTPException( @@ -143,11 +144,12 @@ async def oauth_callback(request: Request, provider: str, db: AsyncSession = Dep access_token, session = await create_session(db, user) logger.info("OAuth login success: provider=%s, user_id=%d, email=%s", provider, user.id, email) - # token 通过 HttpOnly cookie 下发(浏览器自动携带); - # expires_at 走 fragment 给前端做 refresh 判断(兼容旧逻辑) + # 会话凭证只经 HttpOnly cookie 下发(浏览器自动携带); + # fragment 仅携带非敏感状态(provider / expires_at),不再包含 + # access token——杜绝 token 暴露给前端 JS / 地址栏 / 历史记录(#63)。 fragment = urlencode( { - "token": access_token, + "provider": provider, "expires_at": session.expires_at.isoformat(), } ) diff --git a/backend/tests_oauth_patch/test_oauth_fix.py b/backend/tests_oauth_patch/test_oauth_fix.py index c95a0a6f..59318a7b 100644 --- a/backend/tests_oauth_patch/test_oauth_fix.py +++ b/backend/tests_oauth_patch/test_oauth_fix.py @@ -169,8 +169,14 @@ async def test_mock_github_authorization_state_callback_and_auth_cookies(monkeyp assert "secure" in login.headers.get("set-cookie", "").lower() callback = await client.get("/api/v1/auth/oauth/github/callback?code=dummy&state=test-state") assert callback.status_code == 302 - assert callback.headers["location"].startswith(PUBLIC_ORIGIN + "/oauth/callback#") + location = callback.headers["location"] + assert location.startswith(PUBLIC_ORIGIN + "/oauth/callback#") assert "topiceye_auth=" in callback.headers.get("set-cookie", "") + # #63:fragment 不含任何凭证,只带非敏感状态 + fragment = location.split("#", 1)[1] + assert "token=" not in fragment + assert "provider=github" in fragment + assert "expires_at=" in fragment assert get_user.await_args.kwargs["email_verified"] is True create_session.assert_awaited_once() @@ -203,6 +209,12 @@ async def test_mock_google_authorization_state_callback_and_auth_cookies(monkeyp assert provider.callback_uri == PUBLIC_ORIGIN + "/api/v1/auth/oauth/google/callback" callback = await client.get("/api/v1/auth/oauth/google/callback?code=dummy&state=test-state") assert callback.status_code == 302 - assert callback.headers["location"].startswith(PUBLIC_ORIGIN + "/oauth/callback#") + location = callback.headers["location"] + assert location.startswith(PUBLIC_ORIGIN + "/oauth/callback#") assert "topiceye_auth=" in callback.headers.get("set-cookie", "") + # #63:fragment 不含任何凭证,只带非敏感状态 + fragment = location.split("#", 1)[1] + assert "token=" not in fragment + assert "provider=google" in fragment + assert "expires_at=" in fragment create_session.assert_awaited_once() diff --git a/frontend/src/app/oauth/callback/page.tsx b/frontend/src/app/oauth/callback/page.tsx index 7a76b660..14a64242 100644 --- a/frontend/src/app/oauth/callback/page.tsx +++ b/frontend/src/app/oauth/callback/page.tsx @@ -12,13 +12,13 @@ type Status = 'loading' | 'error' | 'success'; /** * OAuth 回调消费页。 * - * 后端在 OAuth 成功后 302 到 /oauth/callback#token=xxx&expires_at=xxx, + * 后端在 OAuth 成功后 302 到 /oauth/callback#provider=xx&expires_at=xx, * 失败则 302 到 /oauth/callback?error=xxx。 * - * 认证 token 通过 HttpOnly cookie 下发(浏览器自动携带), - * fragment 中的 token 仅用于兼容与 presence 标记设置。 - * 本页读取 URL fragment → 设置 presence cookie → 拉 me() → 写入 Context → 跳首页, - * 并用 history.replaceState 清理 URL 避免残留。 + * 会话凭证只通过 HttpOnly cookie 下发(浏览器自动携带),本页不读取 + * 任何凭证类 fragment 参数(#63);旧版 #token=... 链接仍能落地, + * 但 token 一律不消费。流程:设置 presence 标记 → 拉 me() → 写入 + * Context → 跳首页,并用 history.replaceState 清理 URL。 */ export default function OauthCallbackPage() { const router = useRouter(); @@ -42,38 +42,29 @@ export default function OauthCallbackPage() { return; } - // 2. 解析 fragment 拿 token + // 2. 解析 fragment 中的非敏感状态(#63:不读取/消费任何凭证参数) const params = new URLSearchParams(location.hash.startsWith('#') ? location.hash.slice(1) : location.hash); - const token = params.get('token'); - const expiresAt = params.get('expires_at'); - - if (!token || !expiresAt) { - if (!cancelled) { - setErrorMsg('OAuth 回调缺少登录凭证,请重新登录'); - setStatus('error'); - } - return; - } + const expiresAt = params.get('expires_at') ?? ''; try { - // token 已在 HttpOnly cookie 中(后端 302 响应设置)。 - // 设置 presence cookie 让前端判断登录状态,然后拉 me()。 + // 会话凭证已在 HttpOnly cookie 中(后端 302 响应设置)。 + // 设置 presence 标记让前端判断登录状态,然后以 cookie 拉 me()。 setAuthToken('1'); const user = await authApi.me(); if (cancelled) return; applyAuthSession({ - access_token: token, + access_token: 'http-only-cookie', // 占位:applyAuthSession 仅作 presence 用 token_type: 'bearer', expires_at: expiresAt, user, }); - // 抹掉 URL 里的 token,防止残留在浏览器历史 + // 清理 URL,防止残留状态参数 history.replaceState(null, '', '/oauth/callback'); router.replace('/'); } catch (err) { - // token 无效或 me() 失败 → 清理并报错 + // cookie 缺失/无效或 me() 失败 → 清理并报错 setAuthToken(null); if (!cancelled) { setErrorMsg(err instanceof Error ? err.message : '登录信息拉取失败,请重新登录'); From 58695c462b27a7b58ef65be2d8e9a1965e8e452f Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 27 Sep 2026 22:58:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(auth):=20OAuth=20fragment=20=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=96=87=E4=BB=B6=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests_oauth_patch/test_oauth_fix.py | 82 +++++++++++++-------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/backend/tests_oauth_patch/test_oauth_fix.py b/backend/tests_oauth_patch/test_oauth_fix.py index 59318a7b..72d70de3 100644 --- a/backend/tests_oauth_patch/test_oauth_fix.py +++ b/backend/tests_oauth_patch/test_oauth_fix.py @@ -4,6 +4,7 @@ fixtures and must never run against a production database. Run with an isolated dummy DATABASE_URL. """ + from __future__ import annotations import os @@ -42,9 +43,9 @@ def __init__(self, public_email=None, emails=None): self.calls = [] self.callback_uri = None self.public_email = public_email - self.emails = emails if emails is not None else [ - {"email": "private@example.com", "primary": True, "verified": True} - ] + self.emails = ( + emails if emails is not None else [{"email": "private@example.com", "primary": True, "verified": True}] + ) async def get(self, endpoint, *, token): self.calls.append(endpoint) @@ -75,19 +76,21 @@ async def test_github_private_email_verified_primary(): @pytest.mark.asyncio async def test_github_unverified_primary_rejected(): - client = FakeGithub(public_email="public@example.com", emails=[ - {"email": "public@example.com", "primary": True, "verified": False} - ]) + client = FakeGithub( + public_email="public@example.com", emails=[{"email": "public@example.com", "primary": True, "verified": False}] + ) with pytest.raises(oauth_routes._OAuthUserInfoError, match="可验证的主邮箱"): await oauth_routes._extract_userinfo(client, "github", {"access_token": "dummy"}) @pytest.mark.asyncio async def test_github_secondary_verified_cannot_override_primary(): - client = FakeGithub(emails=[ - {"email": "unverified@example.com", "primary": True, "verified": False}, - {"email": "secondary@example.com", "primary": False, "verified": True}, - ]) + client = FakeGithub( + emails=[ + {"email": "unverified@example.com", "primary": True, "verified": False}, + {"email": "secondary@example.com", "primary": False, "verified": True}, + ] + ) with pytest.raises(oauth_routes._OAuthUserInfoError, match="可验证的主邮箱"): await oauth_routes._extract_userinfo(client, "github", {"access_token": "dummy"}) @@ -95,22 +98,30 @@ async def test_github_secondary_verified_cannot_override_primary(): @pytest.mark.asyncio async def test_google_prefers_verified_oidc_token_userinfo(): client = SimpleNamespace(userinfo=AsyncMock(side_effect=AssertionError("Unexpected userinfo request"))) - token = {"userinfo": { - "sub": "google-id", "email": "google@example.com", "email_verified": True, "name": "Google" - }} + token = {"userinfo": {"sub": "google-id", "email": "google@example.com", "email_verified": True, "name": "Google"}} assert await oauth_routes._extract_userinfo(client, "google", token) == ( - "google-id", "google@example.com", True, "Google" + "google-id", + "google@example.com", + True, + "Google", ) client.userinfo.assert_not_awaited() def internal_request(): - return Request({ - "type": "http", "asgi": {"version": "3.0"}, "scheme": "http", - "server": ("backend", 8000), "root_path": "", - "path": "/api/v1/auth/oauth/github/login", "query_string": b"", - "headers": [(b"host", b"backend:8000")], "client": ("127.0.0.1", 1234), - }) + return Request( + { + "type": "http", + "asgi": {"version": "3.0"}, + "scheme": "http", + "server": ("backend", 8000), + "root_path": "", + "path": "/api/v1/auth/oauth/github/login", + "query_string": b"", + "headers": [(b"host", b"backend:8000")], + "client": ("127.0.0.1", 1234), + } + ) def test_callback_uses_public_origin_over_internal_proxy(monkeypatch): @@ -136,15 +147,21 @@ def make_app(monkeypatch, provider, provider_client): monkeypatch.setattr(oauth_routes, "ENABLED_PROVIDERS", [provider]) monkeypatch.setattr(oauth_routes.oauth, "create_client", lambda _: provider_client) get_user = AsyncMock(return_value=SimpleNamespace(id=101, email="private@example.com")) - create_session = AsyncMock(return_value=( - "dummy-auth-cookie", SimpleNamespace(expires_at=datetime.now(UTC) + timedelta(hours=1)), - )) + create_session = AsyncMock( + return_value=( + "dummy-auth-cookie", + SimpleNamespace(expires_at=datetime.now(UTC) + timedelta(hours=1)), + ) + ) monkeypatch.setattr(oauth_routes, "get_or_create_oauth_user", get_user) monkeypatch.setattr(oauth_routes, "create_session", create_session) app = FastAPI() app.add_middleware( - SessionMiddleware, secret_key="dummy-test-only-signing-secret", - session_cookie="topiceye_oauth_state", https_only=True, same_site="lax", + SessionMiddleware, + secret_key="dummy-test-only-signing-secret", + session_cookie="topiceye_oauth_state", + https_only=True, + same_site="lax", ) app.include_router(oauth_routes.router, prefix="/api/v1") @@ -160,7 +177,9 @@ async def test_mock_github_authorization_state_callback_and_auth_cookies(monkeyp provider = FakeGithub() app, get_user, create_session = make_app(monkeypatch, "github", provider) async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url=PUBLIC_ORIGIN, follow_redirects=False, + transport=httpx.ASGITransport(app=app), + base_url=PUBLIC_ORIGIN, + follow_redirects=False, ) as client: login = await client.get("/api/v1/auth/oauth/github/login") assert login.status_code in {302, 307} @@ -189,9 +208,10 @@ async def authorize_redirect(self, request, redirect_uri): async def authorize_access_token(self, request): assert request.session["fake_state"] == request.query_params["state"] == "test-state" - return {"access_token": "dummy", "userinfo": { - "sub": "g123", "email": "g@example.com", "email_verified": True, "name": "Google" - }} + return { + "access_token": "dummy", + "userinfo": {"sub": "g123", "email": "g@example.com", "email_verified": True, "name": "Google"}, + } async def userinfo(self, *, token): raise AssertionError("OIDC token already includes userinfo") @@ -202,7 +222,9 @@ async def test_mock_google_authorization_state_callback_and_auth_cookies(monkeyp provider = FakeGoogle() app, get_user, create_session = make_app(monkeypatch, "google", provider) async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url=PUBLIC_ORIGIN, follow_redirects=False, + transport=httpx.ASGITransport(app=app), + base_url=PUBLIC_ORIGIN, + follow_redirects=False, ) as client: login = await client.get("/api/v1/auth/oauth/google/login") assert login.status_code in {302, 307} From 3dd2022ccd33e50679884863f43f21d2c29a41eb Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 27 Sep 2026 23:00:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(auth):=20OAuth=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests_oauth_patch/test_oauth_fix.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests_oauth_patch/test_oauth_fix.py b/backend/tests_oauth_patch/test_oauth_fix.py index 72d70de3..23c29598 100644 --- a/backend/tests_oauth_patch/test_oauth_fix.py +++ b/backend/tests_oauth_patch/test_oauth_fix.py @@ -23,7 +23,6 @@ from app.api.v1 import oauth as oauth_routes # noqa: E402 - PUBLIC_ORIGIN = "https://topic.example.com"