v0.158.0
  1from __future__ import annotations
  2
  3from functools import cached_property
  4from typing import TYPE_CHECKING, Any
  5from urllib.parse import urlparse, urlunparse
  6
  7from plain.http import (
  8    ForbiddenError403,
  9    HTTPException,
 10    NotFoundError404,
 11    QueryDict,
 12    RedirectResponse,
 13    Response,
 14)
 15from plain.runtime import settings
 16from plain.sessions.views import SessionView
 17from plain.urls import reverse
 18from plain.utils.cache import patch_cache_control
 19from plain.views import View
 20
 21from .sessions import logout
 22from .utils import resolve_url
 23
 24if TYPE_CHECKING:
 25    from app.users.models import User
 26
 27try:
 28    from plain.admin.impersonate import get_request_impersonator
 29except ImportError:
 30    get_request_impersonator: Any = None
 31
 32__all__ = [
 33    "AuthView",
 34    "LoginRequired",
 35    "LogoutView",
 36    "redirect_to_login",
 37]
 38
 39
 40class LoginRequired(HTTPException):
 41    """Raised by `check_auth` when a view requires a logged-in user.
 42
 43    Subclasses an HTTPException so generic handlers (logging, APIs, MCP)
 44    treat it as a 401 by default. HTML views rely on `AuthView.handle_exception`
 45    to render a redirect to the configured login page instead.
 46    """
 47
 48    status_code = 401
 49
 50    def __init__(self, login_url: str | None, redirect_field_name: str = "next"):
 51        # Caller is responsible for resolving `login_url` — pass `None`
 52        # to signal "no login page configured, render as 403 instead".
 53        self.login_url = login_url
 54        self.redirect_field_name = redirect_field_name
 55
 56
 57class AuthView(SessionView):
 58    login_required = False
 59    admin_required = False  # Implies login_required
 60    login_url = settings.AUTH_LOGIN_URL
 61
 62    @cached_property
 63    def user(self) -> User | None:
 64        """Get the authenticated user for this request."""
 65        from .requests import get_request_user
 66
 67        return get_request_user(self.request)
 68
 69    def get_template_context(self) -> dict:
 70        """Add user and impersonator to template context."""
 71        context = super().get_template_context()
 72        context["user"] = self.user
 73        return context
 74
 75    def check_auth(self) -> None:
 76        """Raise LoginRequired, ForbiddenError403, or NotFoundError404 when access is denied."""
 77        if not self.login_required and not self.admin_required:
 78            return
 79
 80        if not self.user:
 81            raise LoginRequired(login_url=self.login_url)
 82
 83        if self.admin_required:
 84            # At this point, we know user is authenticated (from check above)
 85            # Check if impersonation is active
 86            if get_request_impersonator and (
 87                impersonator := get_request_impersonator(self.request)
 88            ):
 89                # Impersonators should be able to view admin pages while impersonating.
 90                # There's probably never a case where an impersonator isn't admin, but it can be configured.
 91                if not impersonator.is_admin:
 92                    raise ForbiddenError403(
 93                        "You do not have permission to access this page."
 94                    )
 95                return
 96
 97            if not self.user.is_admin:
 98                # Show a 404 so we don't expose admin urls to non-admin users
 99                raise NotFoundError404()
100
101    def before_request(self) -> None:
102        self.check_auth()
103
104    def handle_exception(self, exc: Exception) -> Response:
105        if isinstance(exc, LoginRequired):
106            if not exc.login_url:
107                # No configured login page — treat as a plain 403 and let
108                # the surrounding view's `handle_exception` chain render it.
109                return super().handle_exception(ForbiddenError403("Login required"))
110
111            path = self.request.build_absolute_uri()
112            resolved_login_url = reverse(exc.login_url)
113            # If the login url is the same scheme and net location then
114            # use the path as the "next" url.
115            login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
116            current_scheme, current_netloc = urlparse(path)[:2]
117            if (not login_scheme or login_scheme == current_scheme) and (
118                not login_netloc or login_netloc == current_netloc
119            ):
120                path = self.request.get_full_path()
121            return redirect_to_login(
122                path,
123                resolved_login_url,
124                exc.redirect_field_name,
125            )
126        return super().handle_exception(exc)
127
128    def after_response(self, response: Response) -> Response:
129        response = super().after_response(response)
130        if self.user:
131            # Make sure it at least has private as a default
132            patch_cache_control(response, private=True)
133        return response
134
135
136class LogoutView(View):
137    def post(self) -> RedirectResponse:
138        logout(self.request)
139        return RedirectResponse("/", status_code=302)
140
141
142def redirect_to_login(
143    next: str, login_url: str | None = None, redirect_field_name: str = "next"
144) -> RedirectResponse:
145    """
146    Redirect the user to the login page, passing the given 'next' page.
147    """
148    resolved_url = resolve_url(login_url or settings.AUTH_LOGIN_URL)
149
150    login_url_parts = list(urlparse(resolved_url))
151    if redirect_field_name:
152        querystring = QueryDict(login_url_parts[4], mutable=True)
153        querystring[redirect_field_name] = next
154        login_url_parts[4] = querystring.urlencode(safe="/")
155
156    return RedirectResponse(
157        str(urlunparse(login_url_parts)), status_code=302, allow_external=True
158    )