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.
- plain_auth-0.0.0/LICENSE +61 -0
- plain_auth-0.0.0/PKG-INFO +10 -0
- plain_auth-0.0.0/plain/auth/README.md +123 -0
- plain_auth-0.0.0/plain/auth/__init__.py +3 -0
- plain_auth-0.0.0/plain/auth/config.py +5 -0
- plain_auth-0.0.0/plain/auth/default_settings.py +13 -0
- plain_auth-0.0.0/plain/auth/middleware.py +27 -0
- plain_auth-0.0.0/plain/auth/sessions.py +151 -0
- plain_auth-0.0.0/plain/auth/signals.py +5 -0
- plain_auth-0.0.0/plain/auth/utils.py +43 -0
- plain_auth-0.0.0/plain/auth/validators.py +18 -0
- plain_auth-0.0.0/plain/auth/views.py +104 -0
- plain_auth-0.0.0/pyproject.toml +17 -0
plain_auth-0.0.0/LICENSE
ADDED
|
@@ -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,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,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"
|