외부 계정 수동 등록 허용

This commit is contained in:
Yun Chan 2026-06-28 11:31:07 +09:00
parent bd389a97cc
commit 8ed185ce6c
9 changed files with 3450 additions and 1484 deletions

View file

@ -25,8 +25,14 @@ from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response,
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from ..auth_types import AccountStatus, RoleName
from ..auth_sessions import (
ManagedUser,
get_managed_user,
get_managed_user_by_email,
has_admin_access,
InactiveUserError,
is_super_admin_email,
SessionUser,
create_session,
record_user_consent,
@ -76,9 +82,17 @@ class MeResponse(BaseModel):
user_id: str
email: str
display_name: str
role: str
role: RoleName
admin_access: bool = False
super_admin: bool = False
account_status: AccountStatus = "approved"
approval_required: bool = False
cohort_ids: list[str]
consent_at: float | None = None
onboarding_completed_at: float | None = None
nickname: str = ""
self_introduction: str = ""
avatar_url: str = ""
class ConsentRequest(BaseModel):
@ -107,7 +121,7 @@ class AuthConfigResponse(BaseModel):
class DevLoginRequest(BaseModel):
email: str
role: Literal["learner", "teacher", "admin"] = "learner"
role: RoleName = "learner"
display_name: str | None = None
@ -205,8 +219,44 @@ def validate_google_identity_domain(
return normalized_email
async def validate_login_identity_email(
*,
email: str | None,
email_verified: bool,
hosted_domain: str | None = None,
) -> tuple[str, ManagedUser | None]:
"""Validate provider email, allowing exact admin-created managed accounts."""
async def managed_user_for_email(normalized_email: str) -> ManagedUser | None:
try:
return await get_managed_user_by_email(normalized_email)
except HTTPException:
return None
try:
normalized_email = validate_google_identity_domain(
email=email,
email_verified=email_verified,
hosted_domain=hosted_domain,
)
except HTTPException as exc:
normalized_email = _normalize_email(email)
if (
exc.status_code == status.HTTP_403_FORBIDDEN
and normalized_email
and _email_domain(normalized_email)
and email_verified
):
managed_user = await managed_user_for_email(normalized_email)
if managed_user is not None:
return normalized_email, managed_user
raise
return normalized_email, await managed_user_for_email(normalized_email)
def _role_for_email(email: str) -> Role:
normalized = _normalize_email(email)
if normalized in _normalize_email_set(settings.auth_super_admin_emails):
return Role.ADMIN
if normalized in _normalize_email_set(settings.auth_admin_emails):
return Role.ADMIN
if normalized in _normalize_email_set(settings.auth_teacher_emails):
@ -214,6 +264,22 @@ def _role_for_email(email: str) -> Role:
return Role.LEARNER
def _role_for_managed_user(managed_user: ManagedUser | None, fallback: Role) -> Role:
if managed_user is None:
return fallback
if managed_user.role == "admin":
return Role.ADMIN
if managed_user.role == "teacher":
return Role.TEACHER
return Role.LEARNER
def _cohort_ids_for_managed_user(managed_user: ManagedUser | None, fallback: list[str]) -> list[str]:
if managed_user is not None and managed_user.cohort_ids:
return list(managed_user.cohort_ids)
return fallback
def _role_for_saml_identity(identity: SamlIdentity) -> Role:
hinted = (identity.role_hint or "").strip().lower()
if hinted in {"admin", "administrator"}:
@ -573,14 +639,48 @@ def _delete_session_cookie(response: Response) -> None:
)
def _me_response(user: SessionUser | Principal) -> MeResponse:
async def _me_response(user: SessionUser | Principal) -> MeResponse:
managed = await get_managed_user(user.user_id)
onboarding_completed_at = getattr(user, "profile_completed_at", None)
if managed:
onboarding_completed_at = (
managed.profile_completed_at
if (
managed.profile_completed_at is not None
and managed.terms_agreed_at is not None
and managed.privacy_agreed_at is not None
and managed.nickname.strip()
and managed.self_introduction.strip()
)
else None
)
account_status = (
managed.account_status
if managed
else getattr(user, "account_status", "approved")
)
email = getattr(user, "email", "")
role = user.role.value if isinstance(user.role, Role) else user.role
stored_admin_access = managed.admin_access if managed else getattr(user, "admin_access", False)
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,
email=email,
display_name=(
(managed.display_name if managed else "")
or getattr(user, "display_name", "")
or email
),
role=role,
admin_access=has_admin_access(email, role, stored_admin_access),
super_admin=is_super_admin_email(email),
account_status=account_status,
approval_required=account_status != "approved",
cohort_ids=user.cohort_ids,
consent_at=getattr(user, "consent_at", None),
consent_at=(managed.consent_at if managed else getattr(user, "consent_at", None)),
onboarding_completed_at=onboarding_completed_at,
nickname=(managed.nickname if managed else ""),
self_introduction=(managed.self_introduction if managed else ""),
avatar_url=(managed.avatar_url if managed else ""),
)
@ -812,7 +912,7 @@ async def callback(
return _oauth_callback_error("issuer_mismatch", request)
try:
email = validate_google_identity_domain(
email, managed_user = await validate_login_identity_email(
email=claims.get("email"),
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
hosted_domain=claims.get("hd"),
@ -825,11 +925,14 @@ async def callback(
hosted_domain=_normalize_domain(str(claims.get("hd") or "")),
)
return _oauth_callback_error("domain_not_allowed", request)
role = _role_for_email(email)
role = _role_for_managed_user(managed_user, _role_for_email(email))
display_name = str(claims.get("name") or email)
cohort_ids = _configured_cohort_ids(
email=email,
hosted_domain=str(claims.get("hd") or ""),
cohort_ids = _cohort_ids_for_managed_user(
managed_user,
_configured_cohort_ids(
email=email,
hosted_domain=str(claims.get("hd") or ""),
),
)
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
try:
@ -882,7 +985,7 @@ async def saml_acs(request: Request) -> RedirectResponse:
try:
identity = parse_fixture_response(encoded_response)
email = validate_google_identity_domain(
email, managed_user = await validate_login_identity_email(
email=identity.email,
email_verified=True,
hosted_domain=_email_domain(identity.email),
@ -890,8 +993,11 @@ async def saml_acs(request: Request) -> RedirectResponse:
except (HTTPException, ValueError):
return _frontend_login_redirect("saml_assertion_invalid", request)
role = _role_for_saml_identity(identity)
cohort_ids = _configured_cohort_ids(email=email, claim_hint=identity.cohort_hint)
role = _role_for_managed_user(managed_user, _role_for_saml_identity(identity))
cohort_ids = _cohort_ids_for_managed_user(
managed_user,
_configured_cohort_ids(email=email, claim_hint=identity.cohort_hint),
)
external_id = _provider_external_id("saml", identity.subject, email)
try:
sid, _ = await create_session(
@ -919,7 +1025,7 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
if not _dev_login_available(request):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled")
email = validate_google_identity_domain(
email, managed_user = await validate_login_identity_email(
email=str(body.email),
email_verified=True,
hosted_domain=_email_domain(str(body.email)),
@ -928,14 +1034,17 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
sid, user = await create_session(
email=email,
display_name=body.display_name or email,
role=body.role,
cohort_ids=_configured_cohort_ids(email=email),
role=_role_for_managed_user(managed_user, Role(body.role)).value,
cohort_ids=_cohort_ids_for_managed_user(
managed_user,
_configured_cohort_ids(email=email),
),
external_id=_provider_external_id("dev", email, email),
)
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)
return await _me_response(user)
@router.post("/logout")
@ -956,6 +1065,8 @@ async def accept_consent(
principal: CurrentPrincipal,
) -> ConsentResponse:
"""Record the current learner's practice-session consent receipt."""
if principal.role != Role.LEARNER and principal.super_admin:
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
if not body.accepted:
@ -970,6 +1081,8 @@ async def accept_consent(
@router.delete("/consent", response_model=ConsentResponse)
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
"""Withdraw practice-session consent until the learner accepts again."""
if principal.role != Role.LEARNER and principal.super_admin:
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
changed = await withdraw_user_consent(principal.user_id)
@ -982,4 +1095,4 @@ async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
@router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse:
"""Return the current authenticated user. Unauthenticated requests are 401."""
return _me_response(principal)
return await _me_response(principal)