plain.auth 0.0.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.
@@ -0,0 +1,61 @@
1
+ ## Plain is released under the BSD 3-Clause License
2
+
3
+ BSD 3-Clause License
4
+
5
+ Copyright (c) 2023, Dropseed, LLC
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ 3. Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+
32
+
33
+ ## This package contains code forked from github.com/django/django
34
+
35
+ Copyright (c) Django Software Foundation and individual contributors.
36
+ All rights reserved.
37
+
38
+ Redistribution and use in source and binary forms, with or without modification,
39
+ are permitted provided that the following conditions are met:
40
+
41
+ 1. Redistributions of source code must retain the above copyright notice,
42
+ this list of conditions and the following disclaimer.
43
+
44
+ 2. Redistributions in binary form must reproduce the above copyright
45
+ notice, this list of conditions and the following disclaimer in the
46
+ documentation and/or other materials provided with the distribution.
47
+
48
+ 3. Neither the name of Django nor the names of its contributors may be used
49
+ to endorse or promote products derived from this software without
50
+ specific prior written permission.
51
+
52
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
53
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
54
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
55
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
56
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
57
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
58
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
59
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
60
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
61
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.1
2
+ Name: plain.auth
3
+ Version: 0.0.0
4
+ Summary:
5
+ Author: Dave Gaeddert
6
+ Author-email: dave.gaeddert@dropseed.dev
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
@@ -0,0 +1,123 @@
1
+ # plain-auth
2
+
3
+ Add users to your app and define which views they can access.
4
+
5
+ To log a user in, you'll want to pair this package with:
6
+
7
+ - `plain-passwords`
8
+ - `plain-oauth`
9
+ - `plain-passkeys` (TBD)
10
+ - `plain-passlinks` (TBD)
11
+
12
+ ## Installation
13
+
14
+ ```python
15
+ # app/settings.py
16
+ INSTALLED_PACKAGES = [
17
+ # ...
18
+ "plain.auth",
19
+ "plain.sessions",
20
+ "plain.passwords",
21
+ ]
22
+
23
+ MIDDLEWARE = [
24
+ "plain.middleware.security.SecurityMiddleware",
25
+ "plain.assets.whitenoise.middleware.WhiteNoiseMiddleware",
26
+ "plain.sessions.middleware.SessionMiddleware", # <--
27
+ "plain.middleware.common.CommonMiddleware",
28
+ "plain.csrf.middleware.CsrfViewMiddleware",
29
+ "plain.auth.middleware.AuthenticationMiddleware", # <--
30
+ "plain.middleware.clickjacking.XFrameOptionsMiddleware",
31
+ ]
32
+
33
+ AUTH_USER_MODEL = "users.User"
34
+ AUTH_LOGIN_URL = "login"
35
+ ```
36
+
37
+ Create your own user model (`plain create users`).
38
+
39
+ ```python
40
+ # app/users/models.py
41
+ from plain import models
42
+ from plain.passwords.models import PasswordField
43
+
44
+
45
+ class User(models.Model):
46
+ email = models.EmailField(unique=True)
47
+ password = PasswordField()
48
+ is_staff = models.BooleanField(default=False)
49
+ created_at = models.DateTimeField(auto_now_add=True)
50
+
51
+ def __str__(self):
52
+ return self.email
53
+ ```
54
+
55
+ Define your URL/view where users can log in.
56
+
57
+ ```python
58
+ # app/urls.py
59
+ from plain.auth.views import LoginView, LogoutView
60
+ from plain.urls import include, path
61
+ from plain.passwords.views import PasswordLoginView
62
+
63
+
64
+ class LoginView(PasswordLoginView):
65
+ template_name = "login.html"
66
+
67
+
68
+ urlpatterns = [
69
+ path("logout/", LogoutView, name="logout"),
70
+ path("login/", LoginView, name="login"),
71
+ ]
72
+ ```
73
+
74
+
75
+ ## Checking if a user is logged in
76
+
77
+ A `request.user` will either be `None` or point to an instance of a your `AUTH_USER_MODEL`.
78
+
79
+ So in templates you can do:
80
+
81
+ ```html
82
+ {% if request.user %}
83
+ <p>Hello, {{ request.user.email }}!</p>
84
+ {% else %}
85
+ <p>You are not logged in.</p>
86
+ {% endif %}
87
+ ```
88
+
89
+ Or in Python:
90
+
91
+ ```python
92
+ if request.user:
93
+ print(f"Hello, {request.user.email}!")
94
+ else:
95
+ print("You are not logged in.")
96
+ ```
97
+
98
+
99
+ ## Restricting views
100
+
101
+ Use the `AuthViewMixin` to restrict views to logged in users, staff users, or custom logic.
102
+
103
+ ```python
104
+ from plain.auth.views import AuthViewMixin
105
+ from plain.exceptions import PermissionDenied
106
+ from plain.views import View
107
+
108
+
109
+ class LoggedInView(AuthViewMixin, View):
110
+ login_required = True
111
+
112
+
113
+ class StaffOnlyView(AuthViewMixin, View):
114
+ login_required = True
115
+ staff_required = True
116
+
117
+
118
+ class CustomPermissionView(AuthViewMixin, View):
119
+ def check_auth(self):
120
+ super().check_auth()
121
+ if not self.request.user.is_special:
122
+ raise PermissionDenied("You're not special!")
123
+ ```
@@ -0,0 +1,3 @@
1
+ from .sessions import get_user, get_user_model, login, logout
2
+
3
+ __all__ = ["login", "logout", "get_user_model", "get_user"]
@@ -0,0 +1,5 @@
1
+ from plain.packages import PackageConfig
2
+
3
+
4
+ class AuthConfig(PackageConfig):
5
+ name = "plain.auth"
@@ -0,0 +1,13 @@
1
+ from importlib.util import find_spec
2
+
3
+ AUTH_USER_MODEL: str
4
+ AUTH_LOGIN_URL: str
5
+
6
+ if find_spec("plain.passwords"):
7
+ # Automatically invalidate sessions on password field change,
8
+ # if the plain-passwords is installed. You can change this value
9
+ # if your password field is named differently, or you want
10
+ # to use a different field to invalidate sessions.
11
+ AUTH_USER_SESSION_HASH_FIELD: str = "password"
12
+ else:
13
+ AUTH_USER_SESSION_HASH_FIELD: str = ""
@@ -0,0 +1,27 @@
1
+ from plain import auth
2
+ from plain.exceptions import ImproperlyConfigured
3
+ from plain.utils.functional import SimpleLazyObject
4
+
5
+
6
+ def get_user(request):
7
+ if not hasattr(request, "_cached_user"):
8
+ request._cached_user = auth.get_user(request)
9
+ return request._cached_user
10
+
11
+
12
+ class AuthenticationMiddleware:
13
+ def __init__(self, get_response):
14
+ self.get_response = get_response
15
+
16
+ def __call__(self, request):
17
+ if not hasattr(request, "session"):
18
+ raise ImproperlyConfigured(
19
+ "The Plain authentication middleware requires session "
20
+ "middleware to be installed. Edit your MIDDLEWARE setting to "
21
+ "insert "
22
+ "'plain.sessions.middleware.SessionMiddleware' before "
23
+ "'plain.auth.middleware.AuthenticationMiddleware'."
24
+ )
25
+ request.user = SimpleLazyObject(lambda: get_user(request))
26
+ response = self.get_response(request)
27
+ return response
@@ -0,0 +1,151 @@
1
+ from plain.csrf.middleware import rotate_token
2
+ from plain.exceptions import ImproperlyConfigured
3
+ from plain.packages import packages as plain_packages
4
+ from plain.runtime import settings
5
+ from plain.utils.crypto import constant_time_compare, salted_hmac
6
+
7
+ from .signals import user_logged_in, user_logged_out
8
+
9
+ USER_ID_SESSION_KEY = "_auth_user_id"
10
+ USER_HASH_SESSION_KEY = "_auth_user_hash"
11
+
12
+
13
+ def _get_user_id_from_session(request):
14
+ # This value in the session is always serialized to a string, so we need
15
+ # to convert it back to Python whenever we access it.
16
+ return get_user_model()._meta.pk.to_python(request.session[USER_ID_SESSION_KEY])
17
+
18
+
19
+ def get_session_auth_hash(user):
20
+ """
21
+ Return an HMAC of the password field.
22
+ """
23
+ return _get_session_auth_hash(user)
24
+
25
+
26
+ def get_session_auth_fallback_hash(user):
27
+ for fallback_secret in settings.SECRET_KEY_FALLBACKS:
28
+ yield _get_session_auth_hash(user, secret=fallback_secret)
29
+
30
+
31
+ def _get_session_auth_hash(user, secret=None):
32
+ key_salt = "plain.auth.get_session_auth_hash"
33
+ return salted_hmac(
34
+ key_salt,
35
+ getattr(user, settings.AUTH_USER_SESSION_HASH_FIELD),
36
+ secret=secret,
37
+ algorithm="sha256",
38
+ ).hexdigest()
39
+
40
+
41
+ def login(request, user):
42
+ """
43
+ Persist a user id and a backend in the request. This way a user doesn't
44
+ have to reauthenticate on every request. Note that data set during
45
+ the anonymous session is retained when the user logs in.
46
+ """
47
+ if settings.AUTH_USER_SESSION_HASH_FIELD:
48
+ session_auth_hash = get_session_auth_hash(user)
49
+ else:
50
+ session_auth_hash = ""
51
+
52
+ if USER_ID_SESSION_KEY in request.session:
53
+ if _get_user_id_from_session(request) != user.pk:
54
+ # To avoid reusing another user's session, create a new, empty
55
+ # session if the existing session corresponds to a different
56
+ # authenticated user.
57
+ request.session.flush()
58
+ elif session_auth_hash and not constant_time_compare(
59
+ request.session.get(USER_HASH_SESSION_KEY, ""), session_auth_hash
60
+ ):
61
+ # If the session hash does not match the current hash, reset the
62
+ # session. Most likely this means the password was changed.
63
+ request.session.flush()
64
+ else:
65
+ request.session.cycle_key()
66
+
67
+ request.session[USER_ID_SESSION_KEY] = user._meta.pk.value_to_string(user)
68
+ request.session[USER_HASH_SESSION_KEY] = session_auth_hash
69
+ if hasattr(request, "user"):
70
+ request.user = user
71
+ rotate_token(request)
72
+ user_logged_in.send(sender=user.__class__, request=request, user=user)
73
+
74
+
75
+ def logout(request):
76
+ """
77
+ Remove the authenticated user's ID from the request and flush their session
78
+ data.
79
+ """
80
+ # Dispatch the signal before the user is logged out so the receivers have a
81
+ # chance to find out *who* logged out.
82
+ user = getattr(request, "user", None)
83
+ user_logged_out.send(sender=user.__class__, request=request, user=user)
84
+ request.session.flush()
85
+ if hasattr(request, "user"):
86
+ request.user = None
87
+
88
+
89
+ def get_user_model():
90
+ """
91
+ Return the User model that is active in this project.
92
+ """
93
+ try:
94
+ return plain_packages.get_model(settings.AUTH_USER_MODEL, require_ready=False)
95
+ except ValueError:
96
+ raise ImproperlyConfigured(
97
+ "AUTH_USER_MODEL must be of the form 'package_label.model_name'"
98
+ )
99
+ except LookupError:
100
+ raise ImproperlyConfigured(
101
+ "AUTH_USER_MODEL refers to model '%s' that has not been installed"
102
+ % settings.AUTH_USER_MODEL
103
+ )
104
+
105
+
106
+ def get_user(request):
107
+ """
108
+ Return the user model instance associated with the given request session.
109
+ If no user is retrieved, return None.
110
+ """
111
+ if USER_ID_SESSION_KEY not in request.session:
112
+ return None
113
+
114
+ user_id = _get_user_id_from_session(request)
115
+
116
+ UserModel = get_user_model()
117
+ try:
118
+ user = UserModel._default_manager.get(pk=user_id)
119
+ except UserModel.DoesNotExist:
120
+ return None
121
+
122
+ # If the user models defines a specific field to also hash and compare
123
+ # (like password), then we verify that the hash of that field is still
124
+ # the same as when the session was created.
125
+ #
126
+ # If it has changed (i.e. password changed), then the session
127
+ # is no longer valid and cleared out.
128
+ if settings.AUTH_USER_SESSION_HASH_FIELD:
129
+ session_hash = request.session.get(USER_HASH_SESSION_KEY)
130
+ if not session_hash:
131
+ session_hash_verified = False
132
+ else:
133
+ session_auth_hash = get_session_auth_hash(user)
134
+ session_hash_verified = constant_time_compare(
135
+ session_hash, session_auth_hash
136
+ )
137
+ if not session_hash_verified:
138
+ # If the current secret does not verify the session, try
139
+ # with the fallback secrets and stop when a matching one is
140
+ # found.
141
+ if session_hash and any(
142
+ constant_time_compare(session_hash, fallback_auth_hash)
143
+ for fallback_auth_hash in get_session_auth_fallback_hash(user)
144
+ ):
145
+ request.session.cycle_key()
146
+ request.session[USER_HASH_SESSION_KEY] = session_auth_hash
147
+ else:
148
+ request.session.flush()
149
+ user = None
150
+
151
+ return user
@@ -0,0 +1,5 @@
1
+ from plain.signals.dispatch import Signal
2
+
3
+ user_logged_in = Signal()
4
+ user_login_failed = Signal()
5
+ user_logged_out = Signal()
@@ -0,0 +1,43 @@
1
+ from plain.urls import NoReverseMatch, reverse
2
+ from plain.utils.functional import Promise
3
+
4
+
5
+ def resolve_url(to, *args, **kwargs):
6
+ """
7
+ Return a URL appropriate for the arguments passed.
8
+
9
+ The arguments could be:
10
+
11
+ * A model: the model's `get_absolute_url()` function will be called.
12
+
13
+ * A view name, possibly with arguments: `urls.reverse()` will be used
14
+ to reverse-resolve the name.
15
+
16
+ * A URL, which will be returned as-is.
17
+ """
18
+ # If it's a model, use get_absolute_url()
19
+ if hasattr(to, "get_absolute_url"):
20
+ return to.get_absolute_url()
21
+
22
+ if isinstance(to, Promise):
23
+ # Expand the lazy instance, as it can cause issues when it is passed
24
+ # further to some Python functions like urlparse.
25
+ to = str(to)
26
+
27
+ # Handle relative URLs
28
+ if isinstance(to, str) and to.startswith(("./", "../")):
29
+ return to
30
+
31
+ # Next try a reverse URL resolution.
32
+ try:
33
+ return reverse(to, args=args, kwargs=kwargs)
34
+ except NoReverseMatch:
35
+ # If this is a callable, re-raise.
36
+ if callable(to):
37
+ raise
38
+ # If this doesn't "feel" like a URL, re-raise.
39
+ if "/" not in to and "." not in to:
40
+ raise
41
+
42
+ # Finally, fall back and assume it's a URL
43
+ return to
@@ -0,0 +1,18 @@
1
+ import re
2
+
3
+ from plain import validators
4
+ from plain.utils.deconstruct import deconstructible
5
+
6
+
7
+ @deconstructible
8
+ class ASCIIUsernameValidator(validators.RegexValidator):
9
+ regex = r"^[\w.@+-]+\Z"
10
+ message = "Enter a valid username. This value may contain only unaccented lowercase a-z and uppercase A-Z letters, numbers, and @/./+/-/_ characters."
11
+ flags = re.ASCII
12
+
13
+
14
+ @deconstructible
15
+ class UnicodeUsernameValidator(validators.RegexValidator):
16
+ regex = r"^[\w.@+-]+\Z"
17
+ message = "Enter a valid username. This value may contain only letters, numbers, and @/./+/-/_ characters."
18
+ flags = 0
@@ -0,0 +1,104 @@
1
+ from urllib.parse import urlparse, urlunparse
2
+
3
+ from plain.exceptions import PermissionDenied
4
+ from plain.http import (
5
+ Http404,
6
+ QueryDict,
7
+ Response,
8
+ ResponseRedirect,
9
+ )
10
+ from plain.runtime import settings
11
+ from plain.urls import reverse
12
+ from plain.views import View
13
+
14
+ from .sessions import logout
15
+ from .utils import resolve_url
16
+
17
+
18
+ class 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
+
24
+ class 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
+
86
+ class LogoutView(View):
87
+ def post(self):
88
+ logout(self.request)
89
+ return ResponseRedirect("/")
90
+
91
+
92
+ def 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))
@@ -0,0 +1,17 @@
1
+ [tool.poetry]
2
+ name = "plain.auth"
3
+ packages = [
4
+ { include = "plain" },
5
+ ]
6
+ version = "0.0.0"
7
+ description = ""
8
+ authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
9
+ # readme = "README.md"
10
+
11
+ [tool.poetry.dependencies]
12
+ python = "^3.11"
13
+
14
+
15
+ [build-system]
16
+ requires = ["poetry-core"]
17
+ build-backend = "poetry.core.masonry.api"