Plain is headed towards 1.0! Subscribe for development updates →

plain.auth

Add users to your app and define which views they can access.

To log a user in, you'll want to pair this package with:

  • plain-passwords
  • plain-oauth
  • plain-passkeys (TBD)
  • plain-passlinks (TBD)

Installation

# app/settings.py
INSTALLED_PACKAGES = [
    # ...
    "plain.auth",
    "plain.sessions",
    "plain.passwords",
]

MIDDLEWARE = [
    "plain.middleware.security.SecurityMiddleware",
    "plain.sessions.middleware.SessionMiddleware",  # <--
    "plain.middleware.common.CommonMiddleware",
    "plain.csrf.middleware.CsrfViewMiddleware",
    "plain.auth.middleware.AuthenticationMiddleware",  # <--
]

AUTH_USER_MODEL = "users.User"
AUTH_LOGIN_URL = "login"

Create your own user model (plain create users).

# app/users/models.py
from plain import models
from plain.passwords.models import PasswordField


class User(models.Model):
    email = models.EmailField(unique=True)
    password = PasswordField()
    is_staff = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.email

Define your URL/view where users can log in.

# app/urls.py
from plain.auth.views import LoginView, LogoutView
from plain.urls import include, path
from plain.passwords.views import PasswordLoginView


class LoginView(PasswordLoginView):
    template_name = "login.html"


urlpatterns = [
    path("logout/", LogoutView, name="logout"),
    path("login/", LoginView, name="login"),
]

Checking if a user is logged in

A request.user will either be None or point to an instance of a your AUTH_USER_MODEL.

So in templates you can do:

{% if request.user %}
    <p>Hello, {{ request.user.email }}!</p>
{% else %}
    <p>You are not logged in.</p>
{% endif %}

Or in Python:

if request.user:
    print(f"Hello, {request.user.email}!")
else:
    print("You are not logged in.")

Restricting views

Use the AuthViewMixin to restrict views to logged in users, staff users, or custom logic.

from plain.auth.views import AuthViewMixin
from plain.exceptions import PermissionDenied
from plain.views import View


class LoggedInView(AuthViewMixin, View):
    login_required = True


class StaffOnlyView(AuthViewMixin, View):
    login_required = True
    staff_required = True


class CustomPermissionView(AuthViewMixin, View):
    def check_auth(self):
        super().check_auth()
        if not self.request.user.is_special:
            raise PermissionDenied("You're not special!")
  1from urllib.parse import urlparse, urlunparse
  2
  3from plain.exceptions import PermissionDenied
  4from plain.http import (
  5    Http404,
  6    QueryDict,
  7    Response,
  8    ResponseRedirect,
  9)
 10from plain.runtime import settings
 11from plain.urls import reverse
 12from plain.views import View
 13
 14from .sessions import logout
 15from .utils import resolve_url
 16
 17
 18class LoginRequired(Exception):
 19    def __init__(self, login_url=None, redirect_field_name="next"):
 20        self.login_url = login_url or settings.AUTH_LOGIN_URL
 21        self.redirect_field_name = redirect_field_name
 22
 23
 24class AuthViewMixin:
 25    login_required = True
 26    staff_required = False
 27    login_url = None
 28
 29    def check_auth(self) -> None:
 30        """
 31        Raises either LoginRequired or PermissionDenied.
 32        - LoginRequired can specify a login_url and redirect_field_name
 33        - PermissionDenied can specify a message
 34        """
 35
 36        if not hasattr(self, "request"):
 37            raise AttributeError(
 38                "AuthViewMixin requires the request attribute to be set."
 39            )
 40
 41        if self.login_required and not self.request.user:
 42            raise LoginRequired(login_url=self.login_url)
 43
 44        if impersonator := getattr(self.request, "impersonator", None):
 45            # Impersonators should be able to view staff pages while impersonating.
 46            # There's probably never a case where an impersonator isn't staff, but it can be configured.
 47            if self.staff_required and not impersonator.is_staff:
 48                raise PermissionDenied(
 49                    "You do not have permission to access this page."
 50                )
 51        elif self.staff_required and not self.request.user.is_staff:
 52            # Show a 404 so we don't expose staff urls to non-staff users
 53            raise Http404()
 54
 55    def get_response(self) -> Response:
 56        if not hasattr(self, "request"):
 57            raise AttributeError(
 58                "AuthViewMixin requires the request attribute to be set."
 59            )
 60
 61        try:
 62            self.check_auth()
 63        except LoginRequired as e:
 64            # Ideally this could be handled elsewhere... like PermissionDenied
 65            # also seems like this code is used multiple places anyway...
 66            # could be easier to get redirect query param
 67            path = self.request.build_absolute_uri()
 68            resolved_login_url = reverse(e.login_url)
 69            # If the login url is the same scheme and net location then use the
 70            # path as the "next" url.
 71            login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
 72            current_scheme, current_netloc = urlparse(path)[:2]
 73            if (not login_scheme or login_scheme == current_scheme) and (
 74                not login_netloc or login_netloc == current_netloc
 75            ):
 76                path = self.request.get_full_path()
 77            return redirect_to_login(
 78                path,
 79                resolved_login_url,
 80                e.redirect_field_name,
 81            )
 82
 83        return super().get_response()  # type: ignore
 84
 85
 86class LogoutView(View):
 87    def post(self):
 88        logout(self.request)
 89        return ResponseRedirect("/")
 90
 91
 92def redirect_to_login(next, login_url=None, redirect_field_name="next"):
 93    """
 94    Redirect the user to the login page, passing the given 'next' page.
 95    """
 96    resolved_url = resolve_url(login_url or settings.AUTH_LOGIN_URL)
 97
 98    login_url_parts = list(urlparse(resolved_url))
 99    if redirect_field_name:
100        querystring = QueryDict(login_url_parts[4], mutable=True)
101        querystring[redirect_field_name] = next
102        login_url_parts[4] = querystring.urlencode(safe="/")
103
104    return ResponseRedirect(urlunparse(login_url_parts))