"""Sign-in with OIDC, sign-out and the signed-in landing page (FR-1, FR-3).""" from __future__ import annotations import logging from django.db import transaction from django.shortcuts import redirect, render from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_GET, require_POST from portal.adapters.directory import DirectoryUnavailable from portal.adapters.idp import Flow, IdpUnavailable, LoginRejected from portal.adapters.registry import get_idp from portal.audit import services as audit from . import identity from .decorators import login_required from .session_store import LOGIN_FLOW from .sessions import start_session logger = logging.getLogger(__name__) def _unavailable(request): return render(request, "accounts/unavailable.html", status=503) def _refused(request, reason: str, username: str = ""): """One generic page for every refusal, so it never says whether the name exists.""" audit.record_failure( username, "signin.refused", ip=request.client_ip, detail={"reason": reason}, ) return render(request, "accounts/refused.html", status=403) def _safe_next(value: str) -> str: if value.startswith("/") and url_has_allowed_host_and_scheme(value, allowed_hosts=None): return value return "/" @require_GET def login(request): if request.portal_user is not None: return redirect("home") try: url, flow = get_idp().begin() except IdpUnavailable: return _unavailable(request) target = _safe_next(request.GET.get("next", "")) request.session[LOGIN_FLOW] = {**flow.as_dict(), "next": target} return redirect(url) @require_GET def callback(request): data = request.session.pop(LOGIN_FLOW, None) # a flow works once try: flow = Flow.from_dict(data) if data else None except KeyError: flow = None if flow is None: return _refused(request, "no_flow") try: found = get_idp().complete(request.GET, flow) except IdpUnavailable: return _unavailable(request) except LoginRejected: return _refused(request, "provider_response") try: user = identity.admit(found.username) except identity.SignInRefused as refusal: return _refused(request, refusal.reason, found.username) except DirectoryUnavailable: return _unavailable(request) start_session(request, user.sam) identity.remember(request, user) with transaction.atomic(): audit.record(user.sam, "signin.succeeded", ip=request.client_ip) return redirect(_safe_next(data.get("next", ""))) @login_required def home(request): return render(request, "accounts/home.html", {"portal_user": request.portal_user}) @require_POST def logout(request): portal_user = request.portal_user if portal_user is not None: with transaction.atomic(): audit.record(portal_user.user.key, "signout", ip=request.client_ip) request.session.flush() # deletes the session row on the server return redirect("signed-out") @require_GET def signed_out(request): return render(request, "accounts/signed_out.html")