Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions backend/app/api/v1/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
}
)
Expand Down
99 changes: 66 additions & 33 deletions backend/tests_oauth_patch/test_oauth_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +23,6 @@

from app.api.v1 import oauth as oauth_routes # noqa: E402


PUBLIC_ORIGIN = "https://topic.example.com"


Expand All @@ -42,9 +42,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)
Expand Down Expand Up @@ -75,42 +75,52 @@ 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"})


@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):
Expand All @@ -136,15 +146,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")

Expand All @@ -160,7 +176,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}
Expand All @@ -169,8 +187,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()

Expand All @@ -183,9 +207,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")
Expand All @@ -196,13 +221,21 @@ 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}
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()
33 changes: 12 additions & 21 deletions frontend/src/app/oauth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 : '登录信息拉取失败,请重新登录');
Expand Down
Loading