Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -1,84 +1,452 @@
"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁.
"""BFF authentication routes.
마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키.
미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단.
1 = Google OIDC 단독, 한신대 SSO 2(R11, Authlib provider 추상화 ).
파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B.
The production path is Google OIDC authorization code + PKCE. Until the DB
session table is wired, the issued browser sessions are server-side in-proc
sessions backed by an opaque HttpOnly cookie. Local development also has a
dev-only server login endpoint so Playwright can exercise auth without trusting
browser localStorage.
"""
from __future__ import annotations
from typing import Annotated, Optional
import base64
import hashlib
import secrets
import time
from dataclasses import dataclass
from typing import Annotated, Literal, Optional
from urllib.parse import urlencode, urlsplit
from fastapi import APIRouter, HTTPException, Query, Response, status
import httpx
from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
from ..config import settings
from ..deps import CurrentPrincipal
from ..deps import CurrentPrincipal, Principal, Role
router = APIRouter(prefix="/auth", tags=["auth"])
GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
OAUTH_STATE_TTL_SECONDS = 10 * 60
@dataclass(slots=True)
class OAuthState:
code_verifier: str
next_path: str
created_at: float
_oauth_states: dict[str, OAuthState] = {}
class MeResponse(BaseModel):
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
class AuthConfigResponse(BaseModel):
google_oauth_configured: bool
allowed_email_domains: list[str]
redirect_uri: str
dev_login_enabled: bool
class DevLoginRequest(BaseModel):
email: str
role: Literal["learner", "teacher", "admin"] = "learner"
display_name: str | None = None
def _normalize_domain(domain: str | None) -> str:
return (domain or "").strip().lower().lstrip("@")
def _normalize_email(email: str | None) -> str:
return (email or "").strip().lower()
def _email_domain(email: str | None) -> str:
value = _normalize_email(email)
if "@" not in value:
return ""
return value.rsplit("@", 1)[1]
def _normalize_email_set(values: list[str]) -> set[str]:
return {email for value in values if (email := _normalize_email(value))}
def allowed_email_domains() -> set[str]:
"""Configured login email domains, normalized for claim checks."""
return {
normalized
for domain in settings.auth_allowed_email_domains
if (normalized := _normalize_domain(domain))
}
def validate_google_identity_domain(
*,
email: str | None,
email_verified: bool,
hosted_domain: str | None = None,
) -> str:
"""Reject Google identities outside the allowed email domain list.
Google Console authorized domains protect app/redirect domains, not user
email domains. After id_token signature/audience/issuer validation, call
this check with the `email`, `email_verified`, and optional `hd` claims.
"""
normalized_email = _normalize_email(email)
domain = _email_domain(normalized_email)
allowed = allowed_email_domains()
if not allowed:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="allowed email domains are not configured",
)
if not normalized_email or not domain:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email claim is required")
if not email_verified:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email is not verified")
if domain not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
hd = _normalize_domain(hosted_domain)
if hd and hd not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="hosted domain is not allowed")
return normalized_email
def _role_for_email(email: str) -> Role:
normalized = _normalize_email(email)
if normalized in _normalize_email_set(settings.auth_admin_emails):
return Role.ADMIN
if normalized in _normalize_email_set(settings.auth_teacher_emails):
return Role.TEACHER
return Role.LEARNER
def _safe_next_path(next_path: str | None) -> str:
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
return "/"
return next_path
def _url_origin(value: str | None) -> str | None:
if not value:
return None
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
def _is_local_origin(origin: str) -> bool:
host = urlsplit(origin).hostname or ""
return host in {"localhost", "127.0.0.1", "::1"}
def _configured_frontend_origins() -> list[str]:
origins: list[str] = []
for value in [settings.frontend_base_url, *settings.cors_origins]:
origin = _url_origin(value)
if origin and origin not in origins:
origins.append(origin)
return origins
def _frontend_origin_for_request(request: Request | None = None) -> str:
origins = _configured_frontend_origins()
fallback = (_url_origin(settings.frontend_base_url) or "http://localhost:5173").rstrip("/")
if request is not None:
for header_name in ("origin", "referer"):
candidate = _url_origin(request.headers.get(header_name))
if candidate in origins:
return candidate
forwarded_host = request.headers.get("x-forwarded-host")
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
hostname = host.rsplit(":", 1)[0].lower() if host else ""
if hostname == "api-vignette.chanpaca.net":
return "https://vignette.chanpaca.net"
if hostname in {"localhost", "127.0.0.1", "::1"}:
return fallback
if not _is_local_origin(fallback):
return fallback
for origin in origins:
hostname = (urlsplit(origin).hostname or "").lower()
if not _is_local_origin(origin) and hostname != "api-vignette.chanpaca.net":
return origin
return fallback
def _frontend_url(path: str, request: Request | None = None) -> str:
base = _frontend_origin_for_request(request)
return f"{base}{_safe_next_path(path)}"
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _prune_oauth_states() -> None:
cutoff = time.time() - OAUTH_STATE_TTL_SECONDS
stale = [key for key, value in _oauth_states.items() if value.created_at < cutoff]
for key in stale:
_oauth_states.pop(key, None)
def _cookie_secure() -> bool:
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
# accepts Secure cookies on localhost, which keeps dev and prod semantics
# aligned.
return settings.is_prod or settings.cookie_name.startswith("__Host-")
def _set_session_cookie(response: Response, sid: str) -> None:
response.set_cookie(
key=settings.cookie_name,
value=sid,
max_age=settings.session_ttl_seconds,
httponly=True,
secure=_cookie_secure(),
samesite="lax",
path="/",
)
if settings.environment == "dev":
response.set_cookie(
key="vignette_sid",
value=sid,
max_age=settings.session_ttl_seconds,
httponly=True,
secure=False,
samesite="lax",
path="/",
)
def _delete_session_cookie(response: Response) -> None:
response.delete_cookie(
settings.cookie_name,
httponly=True,
secure=_cookie_secure(),
samesite="lax",
path="/",
)
if settings.environment == "dev":
response.delete_cookie(
"vignette_sid",
httponly=True,
secure=False,
samesite="lax",
path="/",
)
def _me_response(user: SessionUser | Principal) -> MeResponse:
return MeResponse(
user_id=user.user_id,
email=getattr(user, "email", ""),
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
role=user.role.value if isinstance(user.role, Role) else user.role,
cohort_ids=user.cohort_ids,
)
def _frontend_login_redirect(reason: str, request: Request) -> RedirectResponse:
base_url = _frontend_origin_for_request(request)
return RedirectResponse(f"{base_url}/login?{urlencode({'oauth': reason})}", status_code=302)
def _dev_login_available(request: Request) -> bool:
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
return False
for header_name in ("origin", "referer"):
origin = _url_origin(request.headers.get(header_name))
if origin and not _is_local_origin(origin):
return False
forwarded_host = request.headers.get("x-forwarded-host")
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
origin = _url_origin(f"http://{host}") if host else None
return bool(origin and _is_local_origin(origin))
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config(request: Request) -> AuthConfigResponse:
"""Return non-secret login configuration for the browser login screen."""
return AuthConfigResponse(
google_oauth_configured=bool(
settings.oauth_google_client_id and settings.oauth_google_client_secret
),
allowed_email_domains=sorted(allowed_email_domains()),
redirect_uri=settings.oauth_redirect_uri,
dev_login_enabled=_dev_login_available(request),
)
@router.get("/login")
async def login(
request: Request,
provider: Annotated[str, Query()] = "google",
next: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""OAuth Auth Code + PKCE 시작 (BFF).
절차:
1. code_verifier 생성 -> S256 code_challenge
2. state(CSRF) + verifier 서버 세션(Redis) 저장
3. provider authorize URL 302 (Google OIDC 1)
TODO: Authlib provider 추상화 + Redis state 저장. 현재 스텁 501.
"""
"""Start Google OIDC authorization code + PKCE login."""
if provider != "google":
# 한신대 SSO 는 2차 (R11)
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=f"provider {provider} not yet supported")
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login TODO (Phase 2 트랙 B)")
return _frontend_login_redirect("unsupported_provider", request)
if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
return _frontend_login_redirect("not_configured", request)
_prune_oauth_states()
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
_oauth_states[state] = OAuthState(
code_verifier=verifier,
next_path=_safe_next_path(next),
created_at=time.time(),
)
params = {
"client_id": settings.oauth_google_client_id,
"redirect_uri": settings.oauth_redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"code_challenge": _pkce_challenge(verifier),
"code_challenge_method": "S256",
"prompt": "select_account",
}
return RedirectResponse(f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}", status_code=302)
@router.get("/callback")
async def callback(
response: Response,
request: Request,
code: Annotated[Optional[str], Query()] = None,
state: Annotated[Optional[str], Query()] = None,
) -> RedirectResponse:
"""OAuth 콜백 — code -> token 교환 후 서버 세션 발급.
"""Exchange Google auth code, validate identity, and issue a BFF cookie."""
if not code or not state:
return _frontend_login_redirect("missing_callback", request)
절차:
1. state 검증 (Redis 저장값과 대조, CSRF)
2. code + code_verifier token 교환 (PKCE)
3. id_token 검증 -> user upsert -> role/cohort 매핑
4. Redis 세션 생성 -> __Host- HttpOnly Secure SameSite=Lax 쿠키 set
5. IRB 동의 미이행 동의 게이트로 리다이렉트 (마스터플랜 §7)
TODO: 전체 교환 구현. 현재 스텁 501.
_prune_oauth_states()
stored = _oauth_states.pop(state, None)
if stored is None:
return _frontend_login_redirect("invalid_state", request)
async with httpx.AsyncClient(timeout=10.0) as client:
token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"client_id": settings.oauth_google_client_id,
"client_secret": settings.oauth_google_client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": settings.oauth_redirect_uri,
"code_verifier": stored.code_verifier,
},
headers={"Accept": "application/json"},
)
if token_res.status_code >= 400:
return _frontend_login_redirect("token_exchange_failed", request)
token_payload = token_res.json()
id_token = token_payload.get("id_token")
if not isinstance(id_token, str) or not id_token:
return _frontend_login_redirect("id_token_missing", request)
info_res = await client.get(GOOGLE_TOKENINFO_URL, params={"id_token": id_token})
if info_res.status_code >= 400:
return _frontend_login_redirect("id_token_invalid", request)
claims = info_res.json()
if claims.get("aud") != settings.oauth_google_client_id:
return _frontend_login_redirect("audience_mismatch", request)
issuer = claims.get("iss")
if issuer not in {"accounts.google.com", "https://accounts.google.com"}:
return _frontend_login_redirect("issuer_mismatch", request)
try:
email = validate_google_identity_domain(
email=claims.get("email"),
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
hosted_domain=claims.get("hd"),
)
except HTTPException:
return _frontend_login_redirect("domain_not_allowed", request)
role = _role_for_email(email)
display_name = str(claims.get("name") or email)
try:
sid, _ = await create_session(
email=email,
display_name=display_name,
role=role.value,
cohort_ids=[],
)
except InactiveUserError as exc:
return _frontend_login_redirect("inactive_user", request)
response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302)
_set_session_cookie(response, sid)
return response
@router.post("/dev-login", response_model=MeResponse)
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
"""Dev-only server login for local E2E and manual testing.
This is not a browser-side auth shortcut: the role is stored server-side and
the browser only gets the same opaque HttpOnly cookie used by OAuth.
"""
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback TODO (Phase 2 트랙 B)")
if not _dev_login_available(request):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled")
email = validate_google_identity_domain(
email=str(body.email),
email_verified=True,
hosted_domain=_email_domain(str(body.email)),
)
try:
sid, user = await create_session(
email=email,
display_name=body.display_name or email,
role=body.role,
cohort_ids=[],
)
except InactiveUserError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc
_set_session_cookie(response, sid)
return _me_response(user)
@router.post("/logout")
async def logout(response: Response) -> dict[str, bool]:
"""세션 무효화 (Redis 삭제 + 쿠키 만료). IRB 철회 즉시 무효화 경로 겸용.
TODO: Redis 세션 삭제. 현재 쿠키 만료만.
"""
response.delete_cookie(settings.cookie_name, httponly=True, secure=settings.is_prod, samesite="lax")
async def logout(
response: Response,
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
) -> dict[str, bool]:
"""Revoke the current server session and expire the browser cookie."""
await revoke_session(session_cookie or (dev_session_cookie if settings.environment == "dev" else None))
_delete_session_cookie(response)
return {"ok": True}
@router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse:
"""현재 세션 주체 (프론트 부트스트랩용). 미인증이면 deps 에서 401."""
return MeResponse(
user_id=principal.user_id,
role=principal.role.value,
cohort_ids=principal.cohort_ids,
)
"""Return the current authenticated user. Unauthenticated requests are 401."""
return _me_response(principal)