fix: stabilize auth and generation workflows

This commit is contained in:
2026-04-23 22:31:14 +08:00
parent 4db04e61e9
commit 7e450aa5fc
16 changed files with 335 additions and 127 deletions

View File

@@ -1,8 +1,8 @@
import secrets
from urllib.parse import urlencode
from urllib.parse import quote, unquote, urlencode, urlparse
import httpx
from fastapi import APIRouter, Cookie, Depends, HTTPException, Query
from fastapi import APIRouter, Cookie, Depends, HTTPException, Query, Response
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +26,8 @@ GOOGLE_USER_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
STATE_COOKIE = "oauth_state"
STATE_MAX_AGE = 600 # 10 minutes
NEXT_COOKIE = "oauth_next"
NEXT_MAX_AGE = 600 # 10 minutes
def _set_state_cookie(response: RedirectResponse, provider: str, state: str) -> None:
@@ -39,6 +41,53 @@ def _set_state_cookie(response: RedirectResponse, provider: str, state: str) ->
)
def _is_allowed_frontend_redirect(url: str | None) -> bool:
if not url:
return False
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return False
origin = f"{parsed.scheme}://{parsed.netloc}"
return origin in settings.cors_origins
def _set_next_cookie(response: RedirectResponse, next_url: str | None) -> None:
if not _is_allowed_frontend_redirect(next_url):
return
response.set_cookie(
key=NEXT_COOKIE,
value=quote(next_url or "", safe=""),
httponly=True,
secure=not settings.debug,
samesite="lax",
max_age=NEXT_MAX_AGE,
)
def _decode_next_cookie(next_cookie: str | None) -> str | None:
if not next_cookie:
return None
return unquote(next_cookie)
def _build_default_frontend_redirect(path: str = "/my-stories") -> str:
frontend_origin = settings.cors_origins[0] if settings.cors_origins else "http://localhost:5173"
return f"{frontend_origin.rstrip('/')}{path}"
def _resolve_frontend_redirect(
next_url: str | None,
*,
fallback_path: str = "/my-stories",
) -> str:
if _is_allowed_frontend_redirect(next_url):
return str(next_url)
return _build_default_frontend_redirect(fallback_path)
def _validate_state(state_from_query: str | None, state_cookie: str | None, provider: str):
if not state_from_query or not state_cookie:
raise HTTPException(status_code=400, detail="Missing OAuth state")
@@ -51,7 +100,7 @@ def _validate_state(state_from_query: str | None, state_cookie: str | None, prov
@router.get("/github/signin")
async def github_signin():
async def github_signin(next: str | None = Query(default=None)):
"""Start GitHub OAuth with state protection."""
state = secrets.token_urlsafe(16)
params = {
@@ -63,6 +112,7 @@ async def github_signin():
url = f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}"
response = RedirectResponse(url=url)
_set_state_cookie(response, "github", state)
_set_next_cookie(response, next)
return response
@@ -71,6 +121,7 @@ async def github_callback(
code: str,
state: str | None = Query(default=None),
state_cookie: str | None = Cookie(default=None, alias=STATE_COOKIE),
next_cookie: str | None = Cookie(default=None, alias=NEXT_COOKIE),
db: AsyncSession = Depends(get_db),
):
"""Handle GitHub OAuth callback."""
@@ -112,11 +163,12 @@ async def github_callback(
user_id=str(github_id),
name=user_data.get("name") or user_data.get("login") or "GitHub User",
avatar_url=user_data.get("avatar_url"),
next_url=_decode_next_cookie(next_cookie),
)
@router.get("/google/signin")
async def google_signin():
async def google_signin(next: str | None = Query(default=None)):
"""Start Google OAuth with state protection."""
state = secrets.token_urlsafe(16)
params = {
@@ -129,6 +181,7 @@ async def google_signin():
url = f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}"
response = RedirectResponse(url=url)
_set_state_cookie(response, "google", state)
_set_next_cookie(response, next)
return response
@@ -137,6 +190,7 @@ async def google_callback(
code: str,
state: str | None = Query(default=None),
state_cookie: str | None = Cookie(default=None, alias=STATE_COOKIE),
next_cookie: str | None = Cookie(default=None, alias=NEXT_COOKIE),
db: AsyncSession = Depends(get_db),
):
"""Handle Google OAuth callback."""
@@ -179,6 +233,7 @@ async def google_callback(
user_id=str(google_id),
name=user_data.get("name") or user_data.get("email") or "Google User",
avatar_url=user_data.get("picture"),
next_url=_decode_next_cookie(next_cookie),
)
@@ -188,6 +243,7 @@ async def _handle_oauth_user(
user_id: str,
name: str,
avatar_url: str | None,
next_url: str | None = None,
) -> RedirectResponse:
"""Create/update user and issue session cookie."""
full_id = f"{provider}:{user_id}"
@@ -211,11 +267,10 @@ async def _handle_oauth_user(
token = create_access_token({"sub": user.id})
frontend_url = "http://localhost:5173"
if settings.cors_origins and len(settings.cors_origins) > 0:
frontend_url = settings.cors_origins[0]
response = RedirectResponse(url=f"{frontend_url}/my-stories", status_code=302)
response = RedirectResponse(
url=_resolve_frontend_redirect(next_url, fallback_path="/my-stories"),
status_code=302,
)
response.set_cookie(
key="access_token",
value=token,
@@ -225,15 +280,17 @@ async def _handle_oauth_user(
max_age=60 * 60 * 24 * 7, # align with ACCESS_TOKEN_EXPIRE_DAYS
)
response.delete_cookie(STATE_COOKIE)
response.delete_cookie(NEXT_COOKIE)
return response
@router.post("/signout")
@router.post("/signout", status_code=204)
async def signout():
"""Sign out and clear cookies."""
response = RedirectResponse(url=settings.cors_origins[0], status_code=302)
response = Response(status_code=204)
response.delete_cookie("access_token", samesite="lax", secure=not settings.debug)
response.delete_cookie(STATE_COOKIE, samesite="lax", secure=not settings.debug)
response.delete_cookie(NEXT_COOKIE, samesite="lax", secure=not settings.debug)
return response
@@ -253,7 +310,10 @@ async def get_session(user: User | None = Depends(get_current_user)):
@router.get("/dev/signin")
async def dev_signin(db: AsyncSession = Depends(get_db)):
async def dev_signin(
next: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
):
"""Developer backdoor login. Only works in DEBUG mode."""
if not settings.debug:
raise HTTPException(status_code=403, detail="Developer login disabled")
@@ -264,7 +324,8 @@ async def dev_signin(db: AsyncSession = Depends(get_db)):
provider="github",
user_id="dev_user_001",
name="Developer",
avatar_url="https://api.dicebear.com/7.x/avataaars/svg?seed=Developer"
avatar_url="https://api.dicebear.com/7.x/avataaars/svg?seed=Developer",
next_url=next,
)
except Exception as e:
import traceback