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