대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -25,6 +25,13 @@ from pydantic import BaseModel
|
|||
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
|
||||
from ..config import settings
|
||||
from ..deps import CurrentPrincipal, Principal, Role
|
||||
from ..saml import (
|
||||
SamlIdentity,
|
||||
acs_url_for_entity_id,
|
||||
build_authn_request,
|
||||
parse_fixture_response,
|
||||
redirect_binding_url,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
|
@ -41,7 +48,15 @@ class OAuthState:
|
|||
created_at: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SamlState:
|
||||
request_id: str
|
||||
next_path: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_oauth_states: dict[str, OAuthState] = {}
|
||||
_saml_states: dict[str, SamlState] = {}
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
|
|
@ -52,8 +67,17 @@ class MeResponse(BaseModel):
|
|||
cohort_ids: list[str]
|
||||
|
||||
|
||||
class AuthProviderStatus(BaseModel):
|
||||
provider: Literal["google", "saml"]
|
||||
configured: bool
|
||||
enabled: bool
|
||||
login_path: str
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
google_oauth_configured: bool
|
||||
saml_configured: bool
|
||||
providers: list[AuthProviderStatus]
|
||||
allowed_email_domains: list[str]
|
||||
redirect_uri: str
|
||||
dev_login_enabled: bool
|
||||
|
|
@ -84,6 +108,37 @@ def _normalize_email_set(values: list[str]) -> set[str]:
|
|||
return {email for value in values if (email := _normalize_email(value))}
|
||||
|
||||
|
||||
def _google_configured() -> bool:
|
||||
return bool(settings.oauth_google_client_id and settings.oauth_google_client_secret)
|
||||
|
||||
|
||||
def _saml_configured() -> bool:
|
||||
return bool(
|
||||
settings.auth_saml_enabled
|
||||
and settings.saml_sp_entity_id.strip()
|
||||
and settings.saml_sso_url.strip()
|
||||
)
|
||||
|
||||
|
||||
def _auth_provider_statuses() -> list[AuthProviderStatus]:
|
||||
google_ready = _google_configured()
|
||||
saml_ready = _saml_configured()
|
||||
return [
|
||||
AuthProviderStatus(
|
||||
provider="google",
|
||||
configured=google_ready,
|
||||
enabled=google_ready,
|
||||
login_path="/auth/login?provider=google",
|
||||
),
|
||||
AuthProviderStatus(
|
||||
provider="saml",
|
||||
configured=saml_ready,
|
||||
enabled=saml_ready,
|
||||
login_path="/auth/login?provider=saml",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def allowed_email_domains() -> set[str]:
|
||||
"""Configured login email domains, normalized for claim checks."""
|
||||
return {
|
||||
|
|
@ -137,6 +192,15 @@ def _role_for_email(email: str) -> Role:
|
|||
return Role.LEARNER
|
||||
|
||||
|
||||
def _role_for_saml_identity(identity: SamlIdentity) -> Role:
|
||||
hinted = (identity.role_hint or "").strip().lower()
|
||||
if hinted in {"admin", "administrator"}:
|
||||
return Role.ADMIN
|
||||
if hinted in {"teacher", "instructor", "faculty"}:
|
||||
return Role.TEACHER
|
||||
return _role_for_email(identity.email)
|
||||
|
||||
|
||||
def _safe_next_path(next_path: str | None) -> str:
|
||||
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
|
||||
return "/"
|
||||
|
|
@ -211,6 +275,13 @@ def _prune_oauth_states() -> None:
|
|||
_oauth_states.pop(key, None)
|
||||
|
||||
|
||||
def _prune_saml_states() -> None:
|
||||
cutoff = time.time() - OAUTH_STATE_TTL_SECONDS
|
||||
stale = [key for key, value in _saml_states.items() if value.created_at < cutoff]
|
||||
for key in stale:
|
||||
_saml_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
|
||||
|
|
@ -291,10 +362,12 @@ def _dev_login_available(request: Request) -> bool:
|
|||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config(request: Request) -> AuthConfigResponse:
|
||||
"""Return non-secret login configuration for the browser login screen."""
|
||||
google_ready = _google_configured()
|
||||
saml_ready = _saml_configured()
|
||||
return AuthConfigResponse(
|
||||
google_oauth_configured=bool(
|
||||
settings.oauth_google_client_id and settings.oauth_google_client_secret
|
||||
),
|
||||
google_oauth_configured=google_ready,
|
||||
saml_configured=saml_ready,
|
||||
providers=_auth_provider_statuses(),
|
||||
allowed_email_domains=sorted(allowed_email_domains()),
|
||||
redirect_uri=settings.oauth_redirect_uri,
|
||||
dev_login_enabled=_dev_login_available(request),
|
||||
|
|
@ -308,9 +381,33 @@ async def login(
|
|||
next: Annotated[str | None, Query()] = None,
|
||||
) -> RedirectResponse:
|
||||
"""Start Google OIDC authorization code + PKCE login."""
|
||||
if provider == "saml":
|
||||
if not _saml_configured():
|
||||
return _frontend_login_redirect("saml_not_configured", request)
|
||||
_prune_saml_states()
|
||||
relay_state = secrets.token_urlsafe(32)
|
||||
acs_url = acs_url_for_entity_id(settings.saml_sp_entity_id)
|
||||
request_id, authn_request_xml = build_authn_request(
|
||||
sp_entity_id=settings.saml_sp_entity_id,
|
||||
sso_url=settings.saml_sso_url,
|
||||
acs_url=acs_url,
|
||||
)
|
||||
_saml_states[relay_state] = SamlState(
|
||||
request_id=request_id,
|
||||
next_path=_safe_next_path(next),
|
||||
created_at=time.time(),
|
||||
)
|
||||
return RedirectResponse(
|
||||
redirect_binding_url(
|
||||
sso_url=settings.saml_sso_url,
|
||||
authn_request_xml=authn_request_xml,
|
||||
relay_state=relay_state,
|
||||
),
|
||||
status_code=302,
|
||||
)
|
||||
if provider != "google":
|
||||
return _frontend_login_redirect("unsupported_provider", request)
|
||||
if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
|
||||
if not _google_configured():
|
||||
return _frontend_login_redirect("not_configured", request)
|
||||
|
||||
_prune_oauth_states()
|
||||
|
|
@ -406,6 +503,58 @@ async def callback(
|
|||
return response
|
||||
|
||||
|
||||
@router.post("/saml/acs")
|
||||
async def saml_acs(request: Request) -> RedirectResponse:
|
||||
"""Accept a minimal unsigned SAMLResponse for local fixture SAML proof.
|
||||
|
||||
Signed SAML verification is intentionally not implemented. When
|
||||
SAML_X509_CERT_FINGERPRINT is configured, this endpoint refuses to trust the
|
||||
response so production does not silently run unsigned SAML.
|
||||
"""
|
||||
if not _saml_configured():
|
||||
return _frontend_login_redirect("saml_not_configured", request)
|
||||
if settings.saml_x509_cert_fingerprint.strip():
|
||||
return _frontend_login_redirect("saml_signature_verification_required", request)
|
||||
if settings.environment != "dev":
|
||||
return _frontend_login_redirect("saml_fixture_acs_dev_only", request)
|
||||
|
||||
form = await request.form()
|
||||
relay_state = str(form.get("RelayState") or "")
|
||||
encoded_response = str(form.get("SAMLResponse") or "")
|
||||
if not relay_state or not encoded_response:
|
||||
return _frontend_login_redirect("saml_missing_callback", request)
|
||||
|
||||
_prune_saml_states()
|
||||
stored = _saml_states.pop(relay_state, None)
|
||||
if stored is None:
|
||||
return _frontend_login_redirect("saml_invalid_state", request)
|
||||
|
||||
try:
|
||||
identity = parse_fixture_response(encoded_response)
|
||||
email = validate_google_identity_domain(
|
||||
email=identity.email,
|
||||
email_verified=True,
|
||||
hosted_domain=_email_domain(identity.email),
|
||||
)
|
||||
except (HTTPException, ValueError):
|
||||
return _frontend_login_redirect("saml_assertion_invalid", request)
|
||||
|
||||
role = _role_for_saml_identity(identity)
|
||||
try:
|
||||
sid, _ = await create_session(
|
||||
email=email,
|
||||
display_name=identity.display_name or email,
|
||||
role=role.value,
|
||||
cohort_ids=[],
|
||||
)
|
||||
except InactiveUserError:
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue