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