Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue