sanic-security 1.11.7__py3-none-any.whl → 1.16.7__py3-none-any.whl
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.
- sanic_security/authentication.py +192 -203
- sanic_security/authorization.py +110 -64
- sanic_security/configuration.py +42 -25
- sanic_security/exceptions.py +58 -24
- sanic_security/models.py +287 -161
- sanic_security/oauth.py +238 -0
- sanic_security/test/server.py +174 -112
- sanic_security/test/tests.py +137 -103
- sanic_security/utils.py +67 -28
- sanic_security/verification.py +59 -46
- sanic_security-1.16.7.dist-info/LICENSE +21 -0
- {sanic_security-1.11.7.dist-info → sanic_security-1.16.7.dist-info}/METADATA +685 -591
- sanic_security-1.16.7.dist-info/RECORD +17 -0
- {sanic_security-1.11.7.dist-info → sanic_security-1.16.7.dist-info}/WHEEL +2 -1
- sanic_security-1.16.7.dist-info/top_level.txt +1 -0
- sanic_security-1.11.7.dist-info/LICENSE +0 -661
- sanic_security-1.11.7.dist-info/RECORD +0 -15
sanic_security/authentication.py
CHANGED
@@ -1,124 +1,46 @@
|
|
1
|
-
import base64
|
2
1
|
import functools
|
3
2
|
import re
|
3
|
+
import warnings
|
4
4
|
|
5
|
-
from argon2 import
|
6
|
-
from argon2.exceptions import VerifyMismatchError
|
5
|
+
from argon2.exceptions import VerificationError, InvalidHashError
|
7
6
|
from sanic import Sanic
|
8
7
|
from sanic.log import logger
|
9
8
|
from sanic.request import Request
|
10
|
-
from tortoise.exceptions import DoesNotExist
|
9
|
+
from tortoise.exceptions import DoesNotExist, ValidationError, IntegrityError
|
11
10
|
|
12
|
-
from sanic_security.configuration import config
|
11
|
+
from sanic_security.configuration import config, DEFAULT_CONFIG
|
13
12
|
from sanic_security.exceptions import (
|
14
|
-
NotFoundError,
|
15
13
|
CredentialsError,
|
16
14
|
DeactivatedError,
|
17
15
|
SecondFactorFulfilledError,
|
16
|
+
ExpiredError,
|
17
|
+
AuditWarning,
|
18
18
|
)
|
19
19
|
from sanic_security.models import Account, AuthenticationSession, Role, TwoStepSession
|
20
|
-
from sanic_security.utils import get_ip
|
20
|
+
from sanic_security.utils import get_ip, password_hasher
|
21
21
|
|
22
22
|
"""
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
23
|
+
Copyright (c) 2020-present Nicholas Aidan Stewart
|
24
|
+
|
25
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
26
|
+
of this software and associated documentation files (the "Software"), to deal
|
27
|
+
in the Software without restriction, including without limitation the rights
|
28
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
29
|
+
copies of the Software, and to permit persons to whom the Software is
|
30
|
+
furnished to do so, subject to the following conditions:
|
31
|
+
|
32
|
+
The above copyright notice and this permission notice shall be included in all
|
33
|
+
copies or substantial portions of the Software.
|
34
|
+
|
35
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
36
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
37
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
38
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
39
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
40
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
41
|
+
SOFTWARE.
|
38
42
|
"""
|
39
43
|
|
40
|
-
password_hasher = PasswordHasher()
|
41
|
-
|
42
|
-
|
43
|
-
def validate_email(email: str) -> str:
|
44
|
-
"""
|
45
|
-
Validates email format.
|
46
|
-
|
47
|
-
Args:
|
48
|
-
email (str): Email being validated.
|
49
|
-
|
50
|
-
Returns:
|
51
|
-
email
|
52
|
-
|
53
|
-
Raises:
|
54
|
-
CredentialsError
|
55
|
-
"""
|
56
|
-
if not re.search(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
|
57
|
-
raise CredentialsError("Please use a valid email address.", 400)
|
58
|
-
return email
|
59
|
-
|
60
|
-
|
61
|
-
def validate_username(username: str) -> str:
|
62
|
-
"""
|
63
|
-
Validates username format.
|
64
|
-
|
65
|
-
Args:
|
66
|
-
username (str): Username being validated.
|
67
|
-
|
68
|
-
Returns:
|
69
|
-
username
|
70
|
-
|
71
|
-
Raises:
|
72
|
-
CredentialsError
|
73
|
-
"""
|
74
|
-
if not re.search(r"^[A-Za-z0-9_-]{3,32}$", username):
|
75
|
-
raise CredentialsError(
|
76
|
-
"Username must be between 3-32 characters and not contain any special characters other than _ or -.",
|
77
|
-
400,
|
78
|
-
)
|
79
|
-
return username
|
80
|
-
|
81
|
-
|
82
|
-
def validate_phone(phone: str) -> str:
|
83
|
-
"""
|
84
|
-
Validates phone number format.
|
85
|
-
|
86
|
-
Args:
|
87
|
-
phone (str): Phone number being validated.
|
88
|
-
|
89
|
-
Returns:
|
90
|
-
phone
|
91
|
-
|
92
|
-
Raises:
|
93
|
-
CredentialsError
|
94
|
-
"""
|
95
|
-
if phone and not re.search(
|
96
|
-
r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", phone
|
97
|
-
):
|
98
|
-
raise CredentialsError("Please use a valid phone number.", 400)
|
99
|
-
return phone
|
100
|
-
|
101
|
-
|
102
|
-
def validate_password(password: str) -> str:
|
103
|
-
"""
|
104
|
-
Validates password requirements.
|
105
|
-
|
106
|
-
Args:
|
107
|
-
password (str): Password being validated.
|
108
|
-
|
109
|
-
Returns:
|
110
|
-
password
|
111
|
-
|
112
|
-
Raises:
|
113
|
-
CredentialsError
|
114
|
-
"""
|
115
|
-
if not re.search(r"^(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&+=!]).*$", password):
|
116
|
-
raise CredentialsError(
|
117
|
-
"Password must contain one capital letter, one number, and one special character",
|
118
|
-
400,
|
119
|
-
)
|
120
|
-
return password
|
121
|
-
|
122
44
|
|
123
45
|
async def register(
|
124
46
|
request: Request, verified: bool = False, disabled: bool = False
|
@@ -137,42 +59,47 @@ async def register(
|
|
137
59
|
Raises:
|
138
60
|
CredentialsError
|
139
61
|
"""
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
62
|
+
try:
|
63
|
+
account = await Account.create(
|
64
|
+
email=request.form.get("email").lower(),
|
65
|
+
username=request.form.get("username"),
|
66
|
+
password=password_hasher.hash(
|
67
|
+
validate_password(request.form.get("password"))
|
68
|
+
),
|
69
|
+
phone=request.form.get("phone"),
|
70
|
+
verified=verified,
|
71
|
+
disabled=disabled,
|
72
|
+
)
|
73
|
+
logger.info(f"Client {get_ip(request)} has registered account {account.id}.")
|
74
|
+
return account
|
75
|
+
except ValidationError as e:
|
76
|
+
raise CredentialsError(
|
77
|
+
"Username must be 3-32 characters long."
|
78
|
+
if "username" in e.args[0]
|
79
|
+
else "Invalid email or phone number."
|
80
|
+
)
|
81
|
+
except IntegrityError as e:
|
82
|
+
raise CredentialsError(
|
83
|
+
f"An account with this {"username" if "username" in str(e.args[0]) else "email or phone number"} "
|
84
|
+
"may already exist.",
|
85
|
+
409,
|
86
|
+
)
|
164
87
|
|
165
88
|
|
166
89
|
async def login(
|
167
|
-
request: Request,
|
90
|
+
request: Request,
|
91
|
+
account: Account = None,
|
92
|
+
require_second_factor: bool = False,
|
93
|
+
password: str = None,
|
168
94
|
) -> AuthenticationSession:
|
169
95
|
"""
|
170
96
|
Login with email or username (if enabled) and password.
|
171
97
|
|
172
98
|
Args:
|
173
|
-
request (Request): Sanic request parameter
|
174
|
-
account (Account): Account being logged into, overrides
|
99
|
+
request (Request): Sanic request parameter, login credentials are retrieved via the authorization header.
|
100
|
+
account (Account): Account being logged into, overrides account retrieved via email or username.
|
175
101
|
require_second_factor (bool): Determines authentication session second factor requirement on login.
|
102
|
+
password (str): Overrides user's password attempt retrieved via the authorization header.
|
176
103
|
|
177
104
|
Returns:
|
178
105
|
authentication_session
|
@@ -184,36 +111,26 @@ async def login(
|
|
184
111
|
UnverifiedError
|
185
112
|
DisabledError
|
186
113
|
"""
|
187
|
-
if request.headers.get("Authorization"):
|
188
|
-
authorization_type, credentials = request.headers.get("Authorization").split()
|
189
|
-
if authorization_type == "Basic":
|
190
|
-
email_or_username, password = (
|
191
|
-
base64.b64decode(credentials).decode().split(":")
|
192
|
-
)
|
193
|
-
else:
|
194
|
-
raise CredentialsError("Invalid authorization type.")
|
195
|
-
else:
|
196
|
-
raise CredentialsError("Credentials not provided.")
|
197
114
|
if not account:
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
if security_config.ALLOW_LOGIN_WITH_USERNAME:
|
202
|
-
account = await Account.get_via_username(email_or_username)
|
203
|
-
else:
|
204
|
-
raise e
|
115
|
+
account, password = await Account.get_via_header(request)
|
116
|
+
elif not password:
|
117
|
+
raise CredentialsError("Password parameter is empty.")
|
205
118
|
try:
|
206
119
|
password_hasher.verify(account.password, password)
|
207
120
|
if password_hasher.check_needs_rehash(account.password):
|
208
121
|
account.password = password_hasher.hash(password)
|
209
122
|
await account.save(update_fields=["password"])
|
210
123
|
account.validate()
|
211
|
-
|
124
|
+
authentication_session = await AuthenticationSession.new(
|
212
125
|
request, account, requires_second_factor=require_second_factor
|
213
126
|
)
|
214
|
-
|
127
|
+
logger.info(
|
128
|
+
f"Client {get_ip(request)} has logged in with authentication session {authentication_session.id}."
|
129
|
+
)
|
130
|
+
return authentication_session
|
131
|
+
except (VerificationError, InvalidHashError):
|
215
132
|
logger.warning(
|
216
|
-
f"Client
|
133
|
+
f"Client {get_ip(request)} has failed to log into account {account.id}."
|
217
134
|
)
|
218
135
|
raise CredentialsError("Incorrect password.", 401)
|
219
136
|
|
@@ -238,18 +155,18 @@ async def logout(request: Request) -> AuthenticationSession:
|
|
238
155
|
raise DeactivatedError("Already logged out.", 403)
|
239
156
|
authentication_session.active = False
|
240
157
|
await authentication_session.save(update_fields=["active"])
|
158
|
+
logger.info(
|
159
|
+
f"Client {get_ip(request)} has logged out with authentication session {authentication_session.id}."
|
160
|
+
)
|
241
161
|
return authentication_session
|
242
162
|
|
243
163
|
|
244
|
-
async def
|
164
|
+
async def fulfill_second_factor(request: Request) -> AuthenticationSession:
|
245
165
|
"""
|
246
|
-
|
166
|
+
Fulfills client authentication session's second factor requirement via two-step session code.
|
247
167
|
|
248
168
|
Args:
|
249
|
-
request (Request): Sanic request parameter.
|
250
|
-
|
251
|
-
Returns:
|
252
|
-
authentication_session
|
169
|
+
request (Request): Sanic request parameter. Request body should contain form-data with the following argument(s): code.
|
253
170
|
|
254
171
|
Raises:
|
255
172
|
NotFoundError
|
@@ -257,50 +174,64 @@ async def authenticate(request: Request) -> AuthenticationSession:
|
|
257
174
|
DeletedError
|
258
175
|
ExpiredError
|
259
176
|
DeactivatedError
|
260
|
-
|
261
|
-
|
262
|
-
|
177
|
+
ChallengeError
|
178
|
+
MaxedOutChallengeError
|
179
|
+
SecondFactorFulfilledError
|
180
|
+
|
181
|
+
Returns:
|
182
|
+
authentication_session
|
263
183
|
"""
|
264
184
|
authentication_session = await AuthenticationSession.decode(request)
|
265
|
-
authentication_session.
|
266
|
-
|
185
|
+
if not authentication_session.requires_second_factor:
|
186
|
+
raise SecondFactorFulfilledError
|
187
|
+
two_step_session = await TwoStepSession.decode(request)
|
188
|
+
two_step_session.validate()
|
189
|
+
await two_step_session.check_code(request.form.get("code"))
|
190
|
+
authentication_session.requires_second_factor = False
|
191
|
+
await authentication_session.save(update_fields=["requires_second_factor"])
|
192
|
+
logger.info(
|
193
|
+
f"Client {get_ip(request)} has fulfilled authentication session {authentication_session.id} second factor."
|
194
|
+
)
|
267
195
|
return authentication_session
|
268
196
|
|
269
197
|
|
270
|
-
async def
|
198
|
+
async def authenticate(request: Request) -> AuthenticationSession:
|
271
199
|
"""
|
272
|
-
|
200
|
+
Validates client's authentication session and account. New/Refreshed session automatically returned if client's
|
201
|
+
session expired during authentication.
|
273
202
|
|
274
203
|
Args:
|
275
|
-
request (Request): Sanic request parameter.
|
204
|
+
request (Request): Sanic request parameter.
|
205
|
+
|
206
|
+
Returns:
|
207
|
+
authentication_session
|
276
208
|
|
277
209
|
Raises:
|
278
210
|
NotFoundError
|
279
211
|
JWTDecodeError
|
280
212
|
DeletedError
|
281
|
-
ExpiredError
|
282
213
|
DeactivatedError
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
Returns:
|
288
|
-
authentication_Session
|
214
|
+
UnverifiedError
|
215
|
+
DisabledError
|
216
|
+
SecondFactorRequiredError
|
217
|
+
ExpiredError
|
289
218
|
"""
|
290
219
|
authentication_session = await AuthenticationSession.decode(request)
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
220
|
+
try:
|
221
|
+
authentication_session.validate()
|
222
|
+
if not authentication_session.anonymous:
|
223
|
+
authentication_session.bearer.validate()
|
224
|
+
except ExpiredError:
|
225
|
+
authentication_session = await authentication_session.refresh(request)
|
226
|
+
authentication_session.is_refresh = True
|
227
|
+
request.ctx.session = authentication_session
|
298
228
|
return authentication_session
|
299
229
|
|
300
230
|
|
301
231
|
def requires_authentication(arg=None):
|
302
232
|
"""
|
303
|
-
Validates client's authentication session and account.
|
233
|
+
Validates client's authentication session and account. New/Refreshed session automatically returned if client's
|
234
|
+
session expired during authentication.
|
304
235
|
|
305
236
|
Example:
|
306
237
|
This method is not called directly and instead used as a decorator:
|
@@ -314,60 +245,118 @@ def requires_authentication(arg=None):
|
|
314
245
|
NotFoundError
|
315
246
|
JWTDecodeError
|
316
247
|
DeletedError
|
317
|
-
ExpiredError
|
318
248
|
DeactivatedError
|
319
249
|
UnverifiedError
|
320
250
|
DisabledError
|
251
|
+
SecondFactorRequiredError
|
252
|
+
ExpiredError
|
321
253
|
"""
|
322
254
|
|
323
255
|
def decorator(func):
|
324
256
|
@functools.wraps(func)
|
325
257
|
async def wrapper(request, *args, **kwargs):
|
326
|
-
|
258
|
+
await authenticate(request)
|
327
259
|
return await func(request, *args, **kwargs)
|
328
260
|
|
329
261
|
return wrapper
|
330
262
|
|
331
|
-
if callable(arg)
|
332
|
-
|
333
|
-
|
334
|
-
|
263
|
+
return decorator(arg) if callable(arg) else decorator
|
264
|
+
|
265
|
+
|
266
|
+
def validate_password(password: str) -> str:
|
267
|
+
"""
|
268
|
+
Validates password requirements.
|
269
|
+
|
270
|
+
Args:
|
271
|
+
password (str): Password being validated.
|
272
|
+
|
273
|
+
Returns:
|
274
|
+
password
|
275
|
+
|
276
|
+
Raises:
|
277
|
+
CredentialsError
|
278
|
+
"""
|
279
|
+
if not re.search(r"^(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&+=!]).*$", password):
|
280
|
+
raise CredentialsError(
|
281
|
+
"Password must contain one capital letter, one number, and one special character",
|
282
|
+
400,
|
283
|
+
)
|
284
|
+
return password
|
335
285
|
|
336
286
|
|
337
|
-
def
|
287
|
+
def initialize_security(app: Sanic, create_root: bool = True) -> None:
|
338
288
|
"""
|
339
|
-
|
289
|
+
Audits configuration, creates root administrator account, and attaches session middleware.
|
340
290
|
|
341
291
|
Args:
|
342
|
-
app (Sanic):
|
292
|
+
app (Sanic): Sanic application instance.
|
293
|
+
create_root (bool): Determines root account creation on initialization.
|
343
294
|
"""
|
344
295
|
|
345
296
|
@app.listener("before_server_start")
|
346
|
-
async def
|
297
|
+
async def audit_configuration(app, loop):
|
298
|
+
if config.SECRET == DEFAULT_CONFIG["SECRET"]:
|
299
|
+
warnings.warn("Secret should be changed from default.", AuditWarning, 2)
|
300
|
+
if not config.SESSION_HTTPONLY:
|
301
|
+
warnings.warn("HttpOnly should be enabled.", AuditWarning, 2)
|
302
|
+
if not config.SESSION_SECURE:
|
303
|
+
warnings.warn("Secure should be enabled.", AuditWarning, 2)
|
304
|
+
if not config.SESSION_SAMESITE or config.SESSION_SAMESITE.lower() == "none":
|
305
|
+
warnings.warn("SameSite should not be none.", AuditWarning, 2)
|
306
|
+
if not config.SESSION_DOMAIN:
|
307
|
+
warnings.warn("Domain should not be none.", AuditWarning, 2)
|
308
|
+
if (
|
309
|
+
create_root
|
310
|
+
and config.INITIAL_ADMIN_EMAIL == DEFAULT_CONFIG["INITIAL_ADMIN_EMAIL"]
|
311
|
+
):
|
312
|
+
warnings.warn(
|
313
|
+
"Initial admin email should be changed from default.", AuditWarning, 2
|
314
|
+
)
|
315
|
+
if (
|
316
|
+
create_root
|
317
|
+
and config.INITIAL_ADMIN_PASSWORD
|
318
|
+
== DEFAULT_CONFIG["INITIAL_ADMIN_PASSWORD"]
|
319
|
+
):
|
320
|
+
warnings.warn(
|
321
|
+
"Initial admin password should be changed from default.",
|
322
|
+
AuditWarning,
|
323
|
+
2,
|
324
|
+
)
|
325
|
+
|
326
|
+
@app.listener("before_server_start")
|
327
|
+
async def create_root_account(app, loop):
|
328
|
+
if not create_root:
|
329
|
+
return
|
347
330
|
try:
|
348
|
-
role = await Role.filter(name="
|
331
|
+
role = await Role.filter(name="Root").get()
|
349
332
|
except DoesNotExist:
|
350
333
|
role = await Role.create(
|
351
|
-
description="Has
|
352
|
-
permissions="*:*",
|
353
|
-
name="
|
334
|
+
description="Has administrator abilities, assign sparingly.",
|
335
|
+
permissions=["*:*"],
|
336
|
+
name="Root",
|
354
337
|
)
|
355
338
|
try:
|
356
|
-
account = await Account.filter(
|
357
|
-
email=security_config.INITIAL_ADMIN_EMAIL
|
358
|
-
).get()
|
339
|
+
account = await Account.filter(email=config.INITIAL_ADMIN_EMAIL).get()
|
359
340
|
await account.fetch_related("roles")
|
360
341
|
if role not in account.roles:
|
361
342
|
await account.roles.add(role)
|
362
|
-
logger.warning(
|
363
|
-
'The initial admin account role "Head Admin" was removed and has been reinstated.'
|
364
|
-
)
|
343
|
+
logger.warning("Initial admin account role has been reinstated.")
|
365
344
|
except DoesNotExist:
|
366
345
|
account = await Account.create(
|
367
|
-
username="
|
368
|
-
email=
|
369
|
-
password=
|
346
|
+
username="Root",
|
347
|
+
email=config.INITIAL_ADMIN_EMAIL,
|
348
|
+
password=password_hasher.hash(config.INITIAL_ADMIN_PASSWORD),
|
370
349
|
verified=True,
|
371
350
|
)
|
372
351
|
await account.roles.add(role)
|
373
352
|
logger.info("Initial admin account created.")
|
353
|
+
|
354
|
+
@app.on_response
|
355
|
+
async def session_middleware(request, response):
|
356
|
+
if hasattr(request.ctx, "session"):
|
357
|
+
if getattr(request.ctx.session, "is_refresh", False):
|
358
|
+
request.ctx.session.encode(response)
|
359
|
+
elif not request.ctx.session.active:
|
360
|
+
response.delete_cookie(
|
361
|
+
f"{config.SESSION_PREFIX}_{request.ctx.session.__class__.__name__[:7].lower()}"
|
362
|
+
)
|