plain.auth 0.19.0__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {plain_auth-0.19.0 → plain_auth-0.20.0}/.gitignore +1 -0
  2. {plain_auth-0.19.0 → plain_auth-0.20.0}/PKG-INFO +1 -2
  3. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/CHANGELOG.md +16 -0
  4. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/README.md +0 -1
  5. plain_auth-0.20.0/plain/auth/__init__.py +9 -0
  6. plain_auth-0.20.0/plain/auth/requests.py +38 -0
  7. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/sessions.py +30 -22
  8. plain_auth-0.20.0/plain/auth/templates.py +14 -0
  9. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/test.py +14 -7
  10. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/views.py +51 -26
  11. {plain_auth-0.19.0 → plain_auth-0.20.0}/pyproject.toml +1 -1
  12. {plain_auth-0.19.0 → plain_auth-0.20.0}/tests/app/settings.py +0 -1
  13. {plain_auth-0.19.0 → plain_auth-0.20.0}/tests/app/urls.py +4 -1
  14. {plain_auth-0.19.0 → plain_auth-0.20.0}/tests/test_views.py +2 -1
  15. plain_auth-0.19.0/plain/auth/__init__.py +0 -8
  16. plain_auth-0.19.0/plain/auth/middleware.py +0 -33
  17. {plain_auth-0.19.0 → plain_auth-0.20.0}/LICENSE +0 -0
  18. {plain_auth-0.19.0 → plain_auth-0.20.0}/README.md +0 -0
  19. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/default_settings.py +0 -0
  20. {plain_auth-0.19.0 → plain_auth-0.20.0}/plain/auth/utils.py +0 -0
  21. {plain_auth-0.19.0 → plain_auth-0.20.0}/tests/app/users/migrations/0001_initial.py +0 -0
  22. {plain_auth-0.19.0 → plain_auth-0.20.0}/tests/app/users/migrations/__init__.py +0 -0
  23. {plain_auth-0.19.0 → plain_auth-0.20.0}/tests/app/users/models.py +0 -0
@@ -16,3 +16,4 @@ plain*/tests/.plain
16
16
 
17
17
  .vscode
18
18
  /.claude
19
+ /.benchmarks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.auth
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Add users to your app and decide what they can access.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -64,7 +64,6 @@ INSTALLED_PACKAGES = [
64
64
 
65
65
  MIDDLEWARE = [
66
66
  "plain.sessions.middleware.SessionMiddleware",
67
- "plain.auth.middleware.AuthenticationMiddleware",
68
67
  ]
69
68
 
70
69
  AUTH_USER_MODEL = "users.User"
@@ -1,5 +1,21 @@
1
1
  # plain-auth changelog
2
2
 
3
+ ## [0.20.0](https://github.com/dropseed/plain/releases/plain-auth@0.20.0) (2025-10-02)
4
+
5
+ ### What's changed
6
+
7
+ - Removed `AuthenticationMiddleware` - authentication is now handled through request-based functions instead of middleware ([154ee10](https://github.com/dropseed/plain/commit/154ee10))
8
+ - Replaced `request.user` attribute with `get_request_user(request)` function and `{{ get_current_user() }}` template global ([154ee10](https://github.com/dropseed/plain/commit/154ee10))
9
+ - `AuthViewMixin` now provides a `self.user` property for accessing the authenticated user in views ([154ee10](https://github.com/dropseed/plain/commit/154ee10))
10
+ - Renamed `get_user` to `get_request_user` in the public API ([154ee10](https://github.com/dropseed/plain/commit/154ee10))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Remove `plain.auth.middleware.AuthenticationMiddleware` from your `MIDDLEWARE` setting
15
+ - In views, use `AuthViewMixin` for access to `self.user` instead of `self.request.user`
16
+ - Replace `request.user` with `get_request_user(request)` in code outside of `AuthViewMixin` views
17
+ - In templates, replace `{{ request.user }}` with `{{ user }}` (from `AuthViewMixin`) or with `{{ get_current_user() }}`
18
+
3
19
  ## [0.19.0](https://github.com/dropseed/plain/releases/plain-auth@0.19.0) (2025-09-30)
4
20
 
5
21
  ### What's changed
@@ -52,7 +52,6 @@ INSTALLED_PACKAGES = [
52
52
 
53
53
  MIDDLEWARE = [
54
54
  "plain.sessions.middleware.SessionMiddleware",
55
- "plain.auth.middleware.AuthenticationMiddleware",
56
55
  ]
57
56
 
58
57
  AUTH_USER_MODEL = "users.User"
@@ -0,0 +1,9 @@
1
+ from .requests import get_request_user
2
+ from .sessions import get_user_model, login, logout
3
+
4
+ __all__ = [
5
+ "login",
6
+ "logout",
7
+ "get_user_model",
8
+ "get_request_user",
9
+ ]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from weakref import WeakKeyDictionary
5
+
6
+ if TYPE_CHECKING:
7
+ from plain.http import Request
8
+
9
+ from .sessions import get_user_model
10
+
11
+ User = get_user_model()
12
+
13
+ _request_users: WeakKeyDictionary[Request, User | None] = WeakKeyDictionary()
14
+
15
+
16
+ def set_request_user(request: Request, user: User | None) -> None:
17
+ """Store the authenticated user for this request."""
18
+ _request_users[request] = user
19
+
20
+
21
+ def get_request_user(request: Request) -> User | None:
22
+ """
23
+ Get the authenticated user for this request, if any.
24
+
25
+ Lazily loads the user from the session on first access.
26
+ """
27
+ if request not in _request_users:
28
+ from .sessions import get_user
29
+
30
+ user = get_user(request)
31
+
32
+ # Don't need to store a bunch of None values
33
+ if not user:
34
+ return None
35
+
36
+ _request_users[request] = user
37
+
38
+ return _request_users[request]
@@ -3,9 +3,12 @@ import hmac
3
3
  from plain.exceptions import ImproperlyConfigured
4
4
  from plain.models import models_registry
5
5
  from plain.runtime import settings
6
+ from plain.sessions import get_request_session
6
7
  from plain.utils.crypto import salted_hmac
7
8
  from plain.utils.encoding import force_bytes
8
9
 
10
+ from .requests import get_request_user, set_request_user
11
+
9
12
  USER_ID_SESSION_KEY = "_auth_user_id"
10
13
  USER_HASH_SESSION_KEY = "_auth_user_hash"
11
14
 
@@ -26,9 +29,11 @@ def update_session_auth_hash(request, user):
26
29
  prevent a password change from logging out the session from which the
27
30
  password was changed.
28
31
  """
29
- request.session.cycle_key()
30
- if request.user == user:
31
- request.session[USER_HASH_SESSION_KEY] = get_session_auth_hash(user)
32
+
33
+ session = get_request_session(request)
34
+ session.cycle_key()
35
+ if get_request_user(request) == user:
36
+ session[USER_HASH_SESSION_KEY] = get_session_auth_hash(user)
32
37
 
33
38
 
34
39
  def get_session_auth_fallback_hash(user):
@@ -52,33 +57,34 @@ def login(request, user):
52
57
  have to reauthenticate on every request. Note that data set during
53
58
  the anonymous session is retained when the user logs in.
54
59
  """
60
+ session = get_request_session(request)
61
+
55
62
  if settings.AUTH_USER_SESSION_HASH_FIELD:
56
63
  session_auth_hash = get_session_auth_hash(user)
57
64
  else:
58
65
  session_auth_hash = ""
59
66
 
60
- if USER_ID_SESSION_KEY in request.session:
61
- if int(request.session[USER_ID_SESSION_KEY]) != user.id:
67
+ if USER_ID_SESSION_KEY in session:
68
+ if int(session[USER_ID_SESSION_KEY]) != user.id:
62
69
  # To avoid reusing another user's session, create a new, empty
63
70
  # session if the existing session corresponds to a different
64
71
  # authenticated user.
65
- request.session.flush()
72
+ session.flush()
66
73
  elif session_auth_hash and not hmac.compare_digest(
67
- force_bytes(request.session.get(USER_HASH_SESSION_KEY, "")),
74
+ force_bytes(session.get(USER_HASH_SESSION_KEY, "")),
68
75
  force_bytes(session_auth_hash),
69
76
  ):
70
77
  # If the session hash does not match the current hash, reset the
71
78
  # session. Most likely this means the password was changed.
72
- request.session.flush()
79
+ session.flush()
73
80
  else:
74
81
  # Invalidate the current session key and generate a new one to enhance security,
75
82
  # typically done after user login to prevent session fixation attacks.
76
- request.session.cycle_key()
83
+ session.cycle_key()
77
84
 
78
- request.session[USER_ID_SESSION_KEY] = user.id
79
- request.session[USER_HASH_SESSION_KEY] = session_auth_hash
80
- if hasattr(request, "user"):
81
- request.user = user
85
+ session[USER_ID_SESSION_KEY] = user.id
86
+ session[USER_HASH_SESSION_KEY] = session_auth_hash
87
+ set_request_user(request, user)
82
88
 
83
89
 
84
90
  def logout(request):
@@ -88,9 +94,9 @@ def logout(request):
88
94
  """
89
95
  # Dispatch the signal before the user is logged out so the receivers have a
90
96
  # chance to find out *who* logged out.
91
- request.session.flush()
92
- if hasattr(request, "user"):
93
- request.user = None
97
+ session = get_request_session(request)
98
+ session.flush()
99
+ set_request_user(request, None)
94
100
 
95
101
 
96
102
  def get_user_model():
@@ -114,12 +120,14 @@ def get_user(request):
114
120
  Return the user model instance associated with the given request session.
115
121
  If no user is retrieved, return None.
116
122
  """
117
- if USER_ID_SESSION_KEY not in request.session:
123
+ session = get_request_session(request)
124
+
125
+ if USER_ID_SESSION_KEY not in session:
118
126
  return None
119
127
 
120
128
  UserModel = get_user_model()
121
129
  try:
122
- user = UserModel.query.get(id=request.session[USER_ID_SESSION_KEY])
130
+ user = UserModel.query.get(id=session[USER_ID_SESSION_KEY])
123
131
  except UserModel.DoesNotExist:
124
132
  return None
125
133
 
@@ -130,7 +138,7 @@ def get_user(request):
130
138
  # If it has changed (i.e. password changed), then the session
131
139
  # is no longer valid and cleared out.
132
140
  if settings.AUTH_USER_SESSION_HASH_FIELD:
133
- session_hash = request.session.get(USER_HASH_SESSION_KEY)
141
+ session_hash = session.get(USER_HASH_SESSION_KEY)
134
142
  if not session_hash:
135
143
  session_hash_verified = False
136
144
  else:
@@ -148,10 +156,10 @@ def get_user(request):
148
156
  )
149
157
  for fallback_auth_hash in get_session_auth_fallback_hash(user)
150
158
  ):
151
- request.session.cycle_key()
152
- request.session[USER_HASH_SESSION_KEY] = session_auth_hash
159
+ session.cycle_key()
160
+ session[USER_HASH_SESSION_KEY] = session_auth_hash
153
161
  else:
154
- request.session.flush()
162
+ session.flush()
155
163
  user = None
156
164
 
157
165
  return user
@@ -0,0 +1,14 @@
1
+ from jinja2 import pass_context
2
+
3
+ from plain.templates import register_template_global
4
+
5
+ from .requests import get_request_user
6
+
7
+
8
+ @register_template_global
9
+ @pass_context
10
+ def get_current_user(context):
11
+ """Get the authenticated user for the current request."""
12
+ request = context.get("request")
13
+ assert request is not None, "No request in template context"
14
+ return get_request_user(request)
@@ -3,7 +3,9 @@ from http.cookies import SimpleCookie
3
3
  from plain.http.request import Request
4
4
  from plain.runtime import settings
5
5
  from plain.sessions import SessionStore
6
+ from plain.sessions.requests import get_request_session, set_request_session
6
7
 
8
+ from .requests import set_request_user
7
9
  from .sessions import get_user, login, logout
8
10
 
9
11
 
@@ -11,13 +13,15 @@ def login_client(client, user):
11
13
  """Log a user into a test client."""
12
14
  request = Request()
13
15
  if client.session:
14
- request.session = client.session
16
+ session = client.session
15
17
  else:
16
- request.session = SessionStore()
18
+ session = SessionStore()
19
+ set_request_session(request, session)
17
20
  login(request, user)
18
- request.session.save()
21
+ session = get_request_session(request)
22
+ session.save()
19
23
  session_cookie = settings.SESSION_COOKIE_NAME
20
- client.cookies[session_cookie] = request.session.session_key
24
+ client.cookies[session_cookie] = session.session_key
21
25
  cookie_data = {
22
26
  "max-age": None,
23
27
  "path": "/",
@@ -32,9 +36,12 @@ def logout_client(client):
32
36
  """Log out a user from a test client."""
33
37
  request = Request()
34
38
  if client.session:
35
- request.session = client.session
36
- request.user = get_user(request)
39
+ session = client.session
40
+ set_request_session(request, session)
41
+ user = get_user(request)
42
+ set_request_user(request, user)
37
43
  else:
38
- request.session = SessionStore()
44
+ session = SessionStore()
45
+ set_request_session(request, session)
39
46
  logout(request)
40
47
  client.cookies = SimpleCookie()
@@ -1,3 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING
1
5
  from urllib.parse import urlparse, urlunparse
2
6
 
3
7
  from plain.exceptions import PermissionDenied
@@ -8,6 +12,7 @@ from plain.http import (
8
12
  ResponseRedirect,
9
13
  )
10
14
  from plain.runtime import settings
15
+ from plain.sessions.views import SessionViewMixin
11
16
  from plain.urls import reverse
12
17
  from plain.utils.cache import patch_cache_control
13
18
  from plain.views import View
@@ -15,6 +20,13 @@ from plain.views import View
15
20
  from .sessions import logout
16
21
  from .utils import resolve_url
17
22
 
23
+ if TYPE_CHECKING:
24
+ from plain.http import Request
25
+
26
+ from .sessions import get_user_model
27
+
28
+ User = get_user_model()
29
+
18
30
 
19
31
  class LoginRequired(Exception):
20
32
  def __init__(self, login_url=None, redirect_field_name="next"):
@@ -22,43 +34,53 @@ class LoginRequired(Exception):
22
34
  self.redirect_field_name = redirect_field_name
23
35
 
24
36
 
25
- class AuthViewMixin:
26
- login_required = True
27
- admin_required = False
37
+ class AuthViewMixin(SessionViewMixin):
38
+ login_required = False
39
+ admin_required = False # Implies login_required
28
40
  login_url = settings.AUTH_LOGIN_URL
29
41
 
42
+ request: Request
43
+
44
+ @cached_property
45
+ def user(self) -> User | None:
46
+ """Get the authenticated user for this request."""
47
+ from .requests import get_request_user
48
+
49
+ return get_request_user(self.request)
50
+
51
+ def get_template_context(self) -> dict:
52
+ """Add user and impersonator to template context."""
53
+ context = super().get_template_context()
54
+ context["user"] = self.user
55
+ return context
56
+
30
57
  def check_auth(self) -> None:
31
58
  """
32
59
  Raises either LoginRequired or PermissionDenied.
33
60
  - LoginRequired can specify a login_url and redirect_field_name
34
61
  - PermissionDenied can specify a message
35
62
  """
63
+ if not self.login_required and not self.admin_required:
64
+ return None
36
65
 
37
- if not hasattr(self, "request"):
38
- raise AttributeError(
39
- "AuthViewMixin requires the request attribute to be set."
40
- )
41
-
42
- if self.login_required and not self.request.user:
66
+ if not self.user:
43
67
  raise LoginRequired(login_url=self.login_url)
44
68
 
45
- if impersonator := getattr(self.request, "impersonator", None):
46
- # Impersonators should be able to view admin pages while impersonating.
47
- # There's probably never a case where an impersonator isn't admin, but it can be configured.
48
- if self.admin_required and not impersonator.is_admin:
49
- raise PermissionDenied(
50
- "You do not have permission to access this page."
51
- )
52
- elif self.admin_required and not self.request.user.is_admin:
53
- # Show a 404 so we don't expose admin urls to non-admin users
54
- raise Http404()
69
+ if self.admin_required:
70
+ # At this point, we know user is authenticated (from check above)
71
+ # Check if impersonation is active
72
+ if impersonator := getattr(self, "impersonator", None):
73
+ # Impersonators should be able to view admin pages while impersonating.
74
+ # There's probably never a case where an impersonator isn't admin, but it can be configured.
75
+ if not impersonator.is_admin:
76
+ raise PermissionDenied(
77
+ "You do not have permission to access this page."
78
+ )
79
+ elif not self.user.is_admin:
80
+ # Show a 404 so we don't expose admin urls to non-admin users
81
+ raise Http404()
55
82
 
56
83
  def get_response(self) -> Response:
57
- if not hasattr(self, "request"):
58
- raise AttributeError(
59
- "AuthViewMixin requires the request attribute to be set."
60
- )
61
-
62
84
  try:
63
85
  self.check_auth()
64
86
  except LoginRequired as e:
@@ -85,8 +107,11 @@ class AuthViewMixin:
85
107
  raise PermissionDenied("Login required")
86
108
 
87
109
  response = super().get_response()
88
- # Make sure it at least has private as a default
89
- patch_cache_control(response, private=True)
110
+
111
+ if self.user:
112
+ # Make sure it at least has private as a default
113
+ patch_cache_control(response, private=True)
114
+
90
115
  return response
91
116
 
92
117
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.auth"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  description = "Add users to your app and decide what they can access."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -8,7 +8,6 @@ INSTALLED_PACKAGES = [
8
8
  ]
9
9
  MIDDLEWARE = [
10
10
  "plain.sessions.middleware.SessionMiddleware",
11
- "plain.auth.middleware.AuthenticationMiddleware",
12
11
  ]
13
12
  AUTH_LOGIN_URL = "login"
14
13
  AUTH_USER_MODEL = "users.User"
@@ -9,12 +9,14 @@ class LoginView(View):
9
9
 
10
10
 
11
11
  class ProtectedView(AuthViewMixin, View):
12
+ login_required = True
13
+
12
14
  def get(self):
13
15
  return "protected"
14
16
 
15
17
 
16
18
  class OpenView(AuthViewMixin, View):
17
- login_required = False
19
+ # login_required = False
18
20
 
19
21
  def get(self):
20
22
  return "open"
@@ -28,6 +30,7 @@ class AdminView(AuthViewMixin, View):
28
30
 
29
31
 
30
32
  class NoLoginUrlView(AuthViewMixin, View):
33
+ login_required = True
31
34
  login_url = None
32
35
 
33
36
  def get(self):
@@ -14,7 +14,7 @@ def test_view_without_login_required(db):
14
14
  response = client.get("/open/")
15
15
  assert response.status_code == 200
16
16
  assert response.content == b"open"
17
- assert response.headers["Cache-Control"] == "private"
17
+ assert "Cache-Control" not in response.headers
18
18
 
19
19
 
20
20
  def test_admin_required(db):
@@ -33,6 +33,7 @@ def test_admin_required(db):
33
33
  resp = client.get("/admin/")
34
34
  assert resp.status_code == 200
35
35
  assert resp.content == b"admin"
36
+ assert resp.headers["Cache-Control"] == "private"
36
37
 
37
38
 
38
39
  def test_no_login_url_forbidden(db):
@@ -1,8 +0,0 @@
1
- from .sessions import get_user, get_user_model, login, logout
2
-
3
- __all__ = [
4
- "login",
5
- "logout",
6
- "get_user_model",
7
- "get_user",
8
- ]
@@ -1,33 +0,0 @@
1
- from opentelemetry import trace
2
- from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID
3
-
4
- from plain import auth
5
- from plain.exceptions import ImproperlyConfigured
6
- from plain.utils.functional import SimpleLazyObject
7
-
8
-
9
- def get_user(request):
10
- if not hasattr(request, "_cached_user"):
11
- request._cached_user = auth.get_user(request)
12
- if request._cached_user:
13
- trace.get_current_span().set_attribute(USER_ID, request._cached_user.id)
14
- return request._cached_user
15
-
16
-
17
- class AuthenticationMiddleware:
18
- def __init__(self, get_response):
19
- self.get_response = get_response
20
-
21
- def __call__(self, request):
22
- if not hasattr(request, "session"):
23
- raise ImproperlyConfigured(
24
- "The Plain authentication middleware requires session "
25
- "middleware to be installed. Edit your MIDDLEWARE setting to "
26
- "insert "
27
- "'plain.sessions.middleware.SessionMiddleware' before "
28
- "'plain.auth.middleware.AuthenticationMiddleware'."
29
- )
30
-
31
- request.user = SimpleLazyObject(lambda: get_user(request))
32
-
33
- return self.get_response(request)
File without changes
File without changes