외부 계정 수동 등록 허용
This commit is contained in:
parent
bd389a97cc
commit
8ed185ce6c
9 changed files with 3450 additions and 1484 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue