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/models.py
CHANGED
@@ -1,36 +1,49 @@
|
|
1
|
+
import base64
|
1
2
|
import datetime
|
2
|
-
|
3
|
+
import logging
|
4
|
+
import re
|
5
|
+
import uuid
|
3
6
|
from typing import Union
|
4
7
|
|
5
8
|
import jwt
|
6
|
-
from captcha.image import ImageCaptcha
|
7
9
|
from jwt import DecodeError
|
8
|
-
from sanic.log import logger
|
9
10
|
from sanic.request import Request
|
10
|
-
from sanic.response import HTTPResponse
|
11
|
+
from sanic.response import HTTPResponse
|
11
12
|
from tortoise import fields, Model
|
12
|
-
from tortoise.exceptions import DoesNotExist
|
13
|
+
from tortoise.exceptions import DoesNotExist, ValidationError
|
14
|
+
from tortoise.validators import RegexValidator
|
13
15
|
|
14
|
-
from sanic_security.configuration import config
|
16
|
+
from sanic_security.configuration import config
|
15
17
|
from sanic_security.exceptions import *
|
16
|
-
from sanic_security.utils import
|
18
|
+
from sanic_security.utils import (
|
19
|
+
get_ip,
|
20
|
+
get_code,
|
21
|
+
get_expiration_date,
|
22
|
+
image_generator,
|
23
|
+
audio_generator,
|
24
|
+
is_expired,
|
25
|
+
)
|
17
26
|
|
18
27
|
"""
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
28
|
+
Copyright (c) 2020-present Nicholas Aidan Stewart
|
29
|
+
|
30
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
31
|
+
of this software and associated documentation files (the "Software"), to deal
|
32
|
+
in the Software without restriction, including without limitation the rights
|
33
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
34
|
+
copies of the Software, and to permit persons to whom the Software is
|
35
|
+
furnished to do so, subject to the following conditions:
|
36
|
+
|
37
|
+
The above copyright notice and this permission notice shall be included in all
|
38
|
+
copies or substantial portions of the Software.
|
39
|
+
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
41
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
42
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
43
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
44
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
45
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
46
|
+
SOFTWARE.
|
34
47
|
"""
|
35
48
|
|
36
49
|
|
@@ -39,30 +52,32 @@ class BaseModel(Model):
|
|
39
52
|
Base Sanic Security model that all other models derive from.
|
40
53
|
|
41
54
|
Attributes:
|
42
|
-
id (
|
55
|
+
id (str): Primary key of model.
|
43
56
|
date_created (datetime): Time this model was created in the database.
|
44
57
|
date_updated (datetime): Time this model was updated in the database.
|
45
58
|
deleted (bool): Renders the model filterable without removing from the database.
|
46
59
|
"""
|
47
60
|
|
48
|
-
id:
|
61
|
+
id: str = fields.CharField(
|
62
|
+
pk=True, max_length=36, default=lambda: str(uuid.uuid4())
|
63
|
+
)
|
49
64
|
date_created: datetime.datetime = fields.DatetimeField(auto_now_add=True)
|
50
65
|
date_updated: datetime.datetime = fields.DatetimeField(auto_now=True)
|
51
66
|
deleted: bool = fields.BooleanField(default=False)
|
52
67
|
|
53
68
|
def validate(self) -> None:
|
54
69
|
"""
|
55
|
-
Raises an error with respect to state.
|
70
|
+
Raises an error with respect to model's state.
|
56
71
|
|
57
72
|
Raises:
|
58
73
|
SecurityError
|
59
74
|
"""
|
60
|
-
raise NotImplementedError
|
75
|
+
raise NotImplementedError
|
61
76
|
|
62
77
|
@property
|
63
78
|
def json(self) -> dict:
|
64
79
|
"""
|
65
|
-
A JSON serializable dict to be used in a
|
80
|
+
A JSON serializable dict to be used in a request or response.
|
66
81
|
|
67
82
|
Example:
|
68
83
|
Below is an example of this method returning a dict to be used for JSON serialization.
|
@@ -79,7 +94,7 @@ class BaseModel(Model):
|
|
79
94
|
}
|
80
95
|
|
81
96
|
"""
|
82
|
-
raise NotImplementedError
|
97
|
+
raise NotImplementedError
|
83
98
|
|
84
99
|
class Meta:
|
85
100
|
abstract = True
|
@@ -93,35 +108,40 @@ class Account(BaseModel):
|
|
93
108
|
username (str): Public identifier.
|
94
109
|
email (str): Private identifier and can be used for verification.
|
95
110
|
phone (str): Mobile phone number with country code included and can be used for verification. Can be null or empty.
|
96
|
-
password (str): Password of account for protection
|
111
|
+
password (str): Password of account for user protection, must be hashed via Argon2.
|
112
|
+
oauth_id (str): Identifier associated with an OAuth authorization flow.
|
97
113
|
disabled (bool): Renders the account unusable but available.
|
98
114
|
verified (bool): Renders the account unusable until verified via two-step verification or other method.
|
99
115
|
roles (ManyToManyRelation[Role]): Roles associated with this account.
|
100
116
|
"""
|
101
117
|
|
102
|
-
username: str = fields.CharField(
|
103
|
-
|
104
|
-
|
118
|
+
username: str = fields.CharField(
|
119
|
+
unique=config.ALLOW_LOGIN_WITH_USERNAME,
|
120
|
+
max_length=32,
|
121
|
+
)
|
122
|
+
email: str = fields.CharField(
|
123
|
+
unique=True,
|
124
|
+
max_length=255,
|
125
|
+
validators=[
|
126
|
+
RegexValidator(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", re.I)
|
127
|
+
],
|
128
|
+
)
|
129
|
+
phone: str = fields.CharField(
|
130
|
+
unique=True,
|
131
|
+
max_length=15,
|
132
|
+
null=True,
|
133
|
+
validators=[
|
134
|
+
RegexValidator(r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", re.I)
|
135
|
+
],
|
136
|
+
)
|
105
137
|
password: str = fields.CharField(max_length=255)
|
138
|
+
oauth_id: str = fields.CharField(unique=True, null=True, max_length=255)
|
106
139
|
disabled: bool = fields.BooleanField(default=False)
|
107
140
|
verified: bool = fields.BooleanField(default=False)
|
108
141
|
roles: fields.ManyToManyRelation["Role"] = fields.ManyToManyField(
|
109
142
|
"models.Role", through="account_role"
|
110
143
|
)
|
111
144
|
|
112
|
-
@property
|
113
|
-
def json(self) -> dict:
|
114
|
-
return {
|
115
|
-
"id": self.id,
|
116
|
-
"date_created": str(self.date_created),
|
117
|
-
"date_updated": str(self.date_updated),
|
118
|
-
"email": self.email,
|
119
|
-
"username": self.username,
|
120
|
-
"phone": self.phone,
|
121
|
-
"disabled": self.disabled,
|
122
|
-
"verified": self.verified,
|
123
|
-
}
|
124
|
-
|
125
145
|
def validate(self) -> None:
|
126
146
|
"""
|
127
147
|
Raises an error with respect to account state.
|
@@ -134,9 +154,9 @@ class Account(BaseModel):
|
|
134
154
|
if self.deleted:
|
135
155
|
raise DeletedError("Account has been deleted.")
|
136
156
|
elif not self.verified:
|
137
|
-
raise UnverifiedError
|
157
|
+
raise UnverifiedError
|
138
158
|
elif self.disabled:
|
139
|
-
raise DisabledError
|
159
|
+
raise DisabledError
|
140
160
|
|
141
161
|
async def disable(self):
|
142
162
|
"""
|
@@ -151,6 +171,19 @@ class Account(BaseModel):
|
|
151
171
|
self.disabled = True
|
152
172
|
await self.save(update_fields=["disabled"])
|
153
173
|
|
174
|
+
@property
|
175
|
+
def json(self) -> dict:
|
176
|
+
return {
|
177
|
+
"id": self.id,
|
178
|
+
"date_created": str(self.date_created),
|
179
|
+
"date_updated": str(self.date_updated),
|
180
|
+
"email": self.email,
|
181
|
+
"username": self.username,
|
182
|
+
"phone": self.phone,
|
183
|
+
"disabled": self.disabled,
|
184
|
+
"verified": self.verified,
|
185
|
+
}
|
186
|
+
|
154
187
|
@staticmethod
|
155
188
|
async def get_via_email(email: str):
|
156
189
|
"""
|
@@ -166,9 +199,8 @@ class Account(BaseModel):
|
|
166
199
|
NotFoundError
|
167
200
|
"""
|
168
201
|
try:
|
169
|
-
|
170
|
-
|
171
|
-
except DoesNotExist:
|
202
|
+
return await Account.filter(email=email.lower(), deleted=False).get()
|
203
|
+
except (DoesNotExist, ValidationError):
|
172
204
|
raise NotFoundError("Account with this email does not exist.")
|
173
205
|
|
174
206
|
@staticmethod
|
@@ -186,15 +218,66 @@ class Account(BaseModel):
|
|
186
218
|
NotFoundError
|
187
219
|
"""
|
188
220
|
try:
|
189
|
-
|
190
|
-
|
191
|
-
except DoesNotExist:
|
221
|
+
return await Account.filter(username=username, deleted=False).get()
|
222
|
+
except (DoesNotExist, ValidationError):
|
192
223
|
raise NotFoundError("Account with this username does not exist.")
|
193
224
|
|
225
|
+
@staticmethod
|
226
|
+
async def get_via_credential(credential: str):
|
227
|
+
"""
|
228
|
+
Retrieve an account with an email or username.
|
229
|
+
|
230
|
+
Args:
|
231
|
+
credential (str): Email or username associated to account being retrieved.
|
232
|
+
|
233
|
+
Returns:
|
234
|
+
account
|
235
|
+
|
236
|
+
Raises:
|
237
|
+
NotFoundError
|
238
|
+
"""
|
239
|
+
try:
|
240
|
+
account = await Account.get_via_email(credential)
|
241
|
+
except NotFoundError as e:
|
242
|
+
if config.ALLOW_LOGIN_WITH_USERNAME:
|
243
|
+
account = await Account.get_via_username(credential)
|
244
|
+
else:
|
245
|
+
raise e
|
246
|
+
return account
|
247
|
+
|
248
|
+
@staticmethod
|
249
|
+
async def get_via_header(request: Request):
|
250
|
+
"""
|
251
|
+
Retrieve an account via the basic authorization header.
|
252
|
+
|
253
|
+
Args:
|
254
|
+
request (Request): Sanic request parameter.
|
255
|
+
|
256
|
+
Returns:
|
257
|
+
account, password
|
258
|
+
|
259
|
+
Raises:
|
260
|
+
NotFoundError
|
261
|
+
"""
|
262
|
+
if request.headers.get("Authorization"):
|
263
|
+
authorization_type, credentials = request.headers.get(
|
264
|
+
"Authorization"
|
265
|
+
).split()
|
266
|
+
if authorization_type == "Basic":
|
267
|
+
email_or_username, password = (
|
268
|
+
base64.b64decode(credentials).decode().split(":")
|
269
|
+
)
|
270
|
+
account = await Account.get_via_credential(email_or_username)
|
271
|
+
return account, password
|
272
|
+
else:
|
273
|
+
raise CredentialsError("Invalid authorization type.")
|
274
|
+
else:
|
275
|
+
raise CredentialsError("Authorization header not provided.")
|
276
|
+
|
194
277
|
@staticmethod
|
195
278
|
async def get_via_phone(phone: str):
|
196
279
|
"""
|
197
|
-
Retrieve an account
|
280
|
+
Retrieve an account via a phone number.
|
198
281
|
|
199
282
|
Args:
|
200
283
|
phone (str): Phone number associated to account being retrieved.
|
@@ -206,9 +289,8 @@ class Account(BaseModel):
|
|
206
289
|
NotFoundError
|
207
290
|
"""
|
208
291
|
try:
|
209
|
-
|
210
|
-
|
211
|
-
except DoesNotExist:
|
292
|
+
return await Account.filter(phone=phone, deleted=False).get()
|
293
|
+
except (DoesNotExist, ValidationError):
|
212
294
|
raise NotFoundError("Account with this phone number does not exist.")
|
213
295
|
|
214
296
|
|
@@ -219,7 +301,7 @@ class Session(BaseModel):
|
|
219
301
|
Attributes:
|
220
302
|
expiration_date (datetime): Date and time the session expires and can no longer be used.
|
221
303
|
active (bool): Determines if the session can be used.
|
222
|
-
ip (str): IP address of client
|
304
|
+
ip (str): IP address of client instantiating session.
|
223
305
|
bearer (ForeignKeyRelation[Account]): Account associated with this session.
|
224
306
|
"""
|
225
307
|
|
@@ -233,19 +315,6 @@ class Session(BaseModel):
|
|
233
315
|
def __init__(self, **kwargs):
|
234
316
|
super().__init__(**kwargs)
|
235
317
|
|
236
|
-
@property
|
237
|
-
def json(self) -> dict:
|
238
|
-
return {
|
239
|
-
"id": self.id,
|
240
|
-
"date_created": str(self.date_created),
|
241
|
-
"date_updated": str(self.date_updated),
|
242
|
-
"expiration_date": str(self.expiration_date),
|
243
|
-
"bearer": self.bearer.username
|
244
|
-
if isinstance(self.bearer, Account)
|
245
|
-
else None,
|
246
|
-
"active": self.active,
|
247
|
-
}
|
248
|
-
|
249
318
|
def validate(self) -> None:
|
250
319
|
"""
|
251
320
|
Raises an error with respect to session state.
|
@@ -257,17 +326,14 @@ class Session(BaseModel):
|
|
257
326
|
"""
|
258
327
|
if self.deleted:
|
259
328
|
raise DeletedError("Session has been deleted.")
|
260
|
-
elif (
|
261
|
-
self.expiration_date
|
262
|
-
and datetime.datetime.now(datetime.timezone.utc) >= self.expiration_date
|
263
|
-
):
|
264
|
-
raise ExpiredError()
|
265
329
|
elif not self.active:
|
266
|
-
raise DeactivatedError
|
330
|
+
raise DeactivatedError
|
331
|
+
elif is_expired(self.expiration_date):
|
332
|
+
raise ExpiredError
|
267
333
|
|
268
|
-
async def deactivate(self):
|
334
|
+
async def deactivate(self) -> None:
|
269
335
|
"""
|
270
|
-
Renders session deactivated and unusable.
|
336
|
+
Renders session deactivated and therefor unusable.
|
271
337
|
|
272
338
|
Raises:
|
273
339
|
DeactivatedError
|
@@ -285,38 +351,55 @@ class Session(BaseModel):
|
|
285
351
|
Args:
|
286
352
|
response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
|
287
353
|
"""
|
288
|
-
payload = {
|
289
|
-
"id": self.id,
|
290
|
-
"date_created": str(self.date_created),
|
291
|
-
"expiration_date": str(self.expiration_date),
|
292
|
-
"ip": self.ip,
|
293
|
-
}
|
294
|
-
cookie = (
|
295
|
-
f"{security_config.SESSION_PREFIX}_{self.__class__.__name__.lower()[:7]}"
|
296
|
-
)
|
297
354
|
encoded_session = jwt.encode(
|
298
|
-
|
355
|
+
{
|
356
|
+
"id": self.id,
|
357
|
+
"date_created": str(self.date_created),
|
358
|
+
"expiration_date": str(self.expiration_date),
|
359
|
+
"bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
|
360
|
+
"ip": self.ip,
|
361
|
+
},
|
362
|
+
config.SECRET,
|
363
|
+
config.SESSION_ENCODING_ALGORITHM,
|
299
364
|
)
|
300
|
-
if isinstance(encoded_session, bytes):
|
301
|
-
encoded_session = encoded_session.decode()
|
302
365
|
response.cookies.add_cookie(
|
303
|
-
|
304
|
-
encoded_session,
|
305
|
-
httponly=
|
306
|
-
samesite=
|
307
|
-
secure=
|
366
|
+
f"{config.SESSION_PREFIX}_{self.__class__.__name__[:7].lower()}",
|
367
|
+
str(encoded_session),
|
368
|
+
httponly=config.SESSION_HTTPONLY,
|
369
|
+
samesite=config.SESSION_SAMESITE,
|
370
|
+
secure=config.SESSION_SECURE,
|
371
|
+
domain=config.SESSION_DOMAIN,
|
372
|
+
expires=getattr(self, "refresh_expiration_date", None)
|
373
|
+
or self.expiration_date,
|
308
374
|
)
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
375
|
+
|
376
|
+
@property
|
377
|
+
def json(self) -> dict:
|
378
|
+
return {
|
379
|
+
"id": self.id,
|
380
|
+
"date_created": str(self.date_created),
|
381
|
+
"date_updated": str(self.date_updated),
|
382
|
+
"expiration_date": str(self.expiration_date),
|
383
|
+
"bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
|
384
|
+
"active": self.active,
|
385
|
+
}
|
386
|
+
|
387
|
+
@property
|
388
|
+
def anonymous(self) -> bool:
|
389
|
+
"""
|
390
|
+
Determines if an account is associated with session.
|
391
|
+
|
392
|
+
Returns:
|
393
|
+
anonymous
|
394
|
+
"""
|
395
|
+
return self.bearer is None
|
313
396
|
|
314
397
|
@classmethod
|
315
398
|
async def new(
|
316
399
|
cls,
|
317
400
|
request: Request,
|
318
401
|
account: Account,
|
319
|
-
**kwargs: Union[int, str, bool, float, list, dict],
|
402
|
+
**kwargs: dict[str, Union[int, str, bool, float, list, dict]],
|
320
403
|
):
|
321
404
|
"""
|
322
405
|
Creates session with pre-set values.
|
@@ -324,12 +407,12 @@ class Session(BaseModel):
|
|
324
407
|
Args:
|
325
408
|
request (Request): Sanic request parameter.
|
326
409
|
account (Account): Account being associated to the session.
|
327
|
-
**kwargs (
|
410
|
+
**kwargs (Union[int, str, bool, float, list, dict]): Extra arguments applied during session creation.
|
328
411
|
|
329
412
|
Returns:
|
330
413
|
session
|
331
414
|
"""
|
332
|
-
raise NotImplementedError
|
415
|
+
raise NotImplementedError
|
333
416
|
|
334
417
|
@classmethod
|
335
418
|
async def get_associated(cls, account: Account):
|
@@ -337,7 +420,7 @@ class Session(BaseModel):
|
|
337
420
|
Retrieves sessions associated to an account.
|
338
421
|
|
339
422
|
Args:
|
340
|
-
account (
|
423
|
+
account (Account): Account associated with sessions being retrieved.
|
341
424
|
|
342
425
|
Returns:
|
343
426
|
sessions
|
@@ -345,7 +428,7 @@ class Session(BaseModel):
|
|
345
428
|
Raises:
|
346
429
|
NotFoundError
|
347
430
|
"""
|
348
|
-
sessions = await cls.filter(bearer=account).
|
431
|
+
sessions = await cls.filter(bearer=account, deleted=False).all()
|
349
432
|
if not sessions:
|
350
433
|
raise NotFoundError("No sessions associated to account were found.")
|
351
434
|
return sessions
|
@@ -353,7 +436,7 @@ class Session(BaseModel):
|
|
353
436
|
@classmethod
|
354
437
|
def decode_raw(cls, request: Request) -> dict:
|
355
438
|
"""
|
356
|
-
Decodes JWT token from client cookie into a python dict.
|
439
|
+
Decodes session JWT token from client cookie into a python dict.
|
357
440
|
|
358
441
|
Args:
|
359
442
|
request (Request): Sanic request parameter.
|
@@ -365,18 +448,16 @@ class Session(BaseModel):
|
|
365
448
|
JWTDecodeError
|
366
449
|
"""
|
367
450
|
cookie = request.cookies.get(
|
368
|
-
f"{
|
451
|
+
f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
|
369
452
|
)
|
370
453
|
try:
|
371
454
|
if not cookie:
|
372
|
-
raise JWTDecodeError
|
455
|
+
raise JWTDecodeError
|
373
456
|
else:
|
374
457
|
return jwt.decode(
|
375
458
|
cookie,
|
376
|
-
|
377
|
-
|
378
|
-
else security_config.PUBLIC_SECRET,
|
379
|
-
security_config.SESSION_ENCODING_ALGORITHM,
|
459
|
+
config.PUBLIC_SECRET or config.SECRET,
|
460
|
+
config.SESSION_ENCODING_ALGORITHM,
|
380
461
|
)
|
381
462
|
except DecodeError as e:
|
382
463
|
raise JWTDecodeError(str(e))
|
@@ -384,7 +465,7 @@ class Session(BaseModel):
|
|
384
465
|
@classmethod
|
385
466
|
async def decode(cls, request: Request):
|
386
467
|
"""
|
387
|
-
Decodes session JWT from client cookie
|
468
|
+
Decodes session JWT token from client cookie into a session model.
|
388
469
|
|
389
470
|
Args:
|
390
471
|
request (Request): Sanic request parameter.
|
@@ -401,6 +482,7 @@ class Session(BaseModel):
|
|
401
482
|
decoded_session = (
|
402
483
|
await cls.filter(id=decoded_raw["id"]).prefetch_related("bearer").get()
|
403
484
|
)
|
485
|
+
request.ctx.session = decoded_session
|
404
486
|
except DoesNotExist:
|
405
487
|
raise NotFoundError("Session could not be found.")
|
406
488
|
return decoded_session
|
@@ -411,66 +493,68 @@ class Session(BaseModel):
|
|
411
493
|
|
412
494
|
class VerificationSession(Session):
|
413
495
|
"""
|
414
|
-
Used for
|
496
|
+
Used for client verification challenges that require some form of code or key.
|
415
497
|
|
416
498
|
Attributes:
|
417
|
-
attempts (int): The amount of
|
418
|
-
code (str):
|
499
|
+
attempts (int): The amount of times a user entered a code not equal to this verification sessions code.
|
500
|
+
code (str): A secret key that would be sent via email, text, etc.
|
419
501
|
"""
|
420
502
|
|
421
503
|
attempts: int = fields.IntField(default=0)
|
422
|
-
code: str = fields.CharField(max_length=
|
423
|
-
|
424
|
-
@classmethod
|
425
|
-
async def new(cls, request: Request, account: Account, **kwargs):
|
426
|
-
raise NotImplementedError
|
504
|
+
code: str = fields.CharField(max_length=6, null=True)
|
427
505
|
|
428
|
-
async def check_code(self,
|
506
|
+
async def check_code(self, code: str) -> None:
|
429
507
|
"""
|
430
508
|
Checks if code passed is equivalent to the session code.
|
431
509
|
|
432
510
|
Args:
|
433
511
|
code (str): Code being cross-checked with session code.
|
434
|
-
request (Request): Sanic request parameter.
|
435
512
|
|
436
513
|
Raises:
|
437
514
|
ChallengeError
|
438
515
|
MaxedOutChallengeError
|
439
516
|
"""
|
440
|
-
if self.code != code.upper():
|
441
|
-
|
442
|
-
|
517
|
+
if not code or self.code != code.upper():
|
518
|
+
self.attempts += 1
|
519
|
+
if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
|
443
520
|
await self.save(update_fields=["attempts"])
|
444
521
|
raise ChallengeError(
|
445
522
|
"Your code does not match verification session code."
|
446
523
|
)
|
447
524
|
else:
|
448
|
-
|
449
|
-
f"Client ({get_ip(request)}) has maxed out on session challenge attempts"
|
450
|
-
)
|
451
|
-
raise MaxedOutChallengeError()
|
525
|
+
raise MaxedOutChallengeError
|
452
526
|
else:
|
453
|
-
self.
|
454
|
-
|
527
|
+
await self.deactivate()
|
528
|
+
|
529
|
+
@classmethod
|
530
|
+
async def new(
|
531
|
+
cls,
|
532
|
+
request: Request,
|
533
|
+
account: Account,
|
534
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
535
|
+
):
|
536
|
+
raise NotImplementedError
|
455
537
|
|
456
538
|
class Meta:
|
457
539
|
abstract = True
|
458
540
|
|
459
541
|
|
460
542
|
class TwoStepSession(VerificationSession):
|
461
|
-
"""
|
462
|
-
Validates a client using a code sent via email or text.
|
463
|
-
"""
|
543
|
+
"""Validates client using a code sent via email or text."""
|
464
544
|
|
465
545
|
@classmethod
|
466
|
-
async def new(
|
467
|
-
|
546
|
+
async def new(
|
547
|
+
cls,
|
548
|
+
request: Request,
|
549
|
+
account: Account,
|
550
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
551
|
+
):
|
552
|
+
return await cls.create(
|
468
553
|
**kwargs,
|
469
554
|
ip=get_ip(request),
|
470
555
|
bearer=account,
|
471
|
-
expiration_date=get_expiration_date(
|
472
|
-
|
473
|
-
),
|
556
|
+
expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
|
557
|
+
code=get_code(True),
|
474
558
|
)
|
475
559
|
|
476
560
|
class Meta:
|
@@ -478,31 +562,38 @@ class TwoStepSession(VerificationSession):
|
|
478
562
|
|
479
563
|
|
480
564
|
class CaptchaSession(VerificationSession):
|
481
|
-
"""
|
482
|
-
Validates a client with a captcha challenge.
|
483
|
-
"""
|
565
|
+
"""Validates client with a captcha challenge via image or audio."""
|
484
566
|
|
485
567
|
@classmethod
|
486
|
-
async def new(
|
487
|
-
|
568
|
+
async def new(
|
569
|
+
cls,
|
570
|
+
request: Request,
|
571
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
572
|
+
):
|
573
|
+
return await cls.create(
|
488
574
|
**kwargs,
|
489
575
|
ip=get_ip(request),
|
490
|
-
|
491
|
-
|
492
|
-
),
|
576
|
+
code=get_code(),
|
577
|
+
expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
|
493
578
|
)
|
494
579
|
|
495
|
-
def get_image(self) ->
|
580
|
+
def get_image(self) -> bytes:
|
496
581
|
"""
|
497
|
-
Retrieves captcha image
|
582
|
+
Retrieves captcha image data.
|
498
583
|
|
499
584
|
Returns:
|
500
585
|
captcha_image
|
501
586
|
"""
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
587
|
+
return image_generator.generate(self.code, "jpeg").getvalue()
|
588
|
+
|
589
|
+
def get_audio(self) -> bytes:
|
590
|
+
"""
|
591
|
+
Retrieves captcha audio data.
|
592
|
+
|
593
|
+
Returns:
|
594
|
+
captcha_audio
|
595
|
+
"""
|
596
|
+
return bytes(audio_generator.generate(self.code))
|
506
597
|
|
507
598
|
class Meta:
|
508
599
|
table = "captcha_session"
|
@@ -513,10 +604,14 @@ class AuthenticationSession(Session):
|
|
513
604
|
Used to authenticate and identify a client.
|
514
605
|
|
515
606
|
Attributes:
|
607
|
+
refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
|
516
608
|
requires_second_factor (bool): Determines if session requires a second factor.
|
609
|
+
is_refresh (bool): Will only be true once when instantiated during the refresh of expired session.
|
517
610
|
"""
|
518
611
|
|
612
|
+
refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
|
519
613
|
requires_second_factor: bool = fields.BooleanField(default=False)
|
614
|
+
is_refresh: bool = False
|
520
615
|
|
521
616
|
def validate(self) -> None:
|
522
617
|
"""
|
@@ -530,18 +625,49 @@ class AuthenticationSession(Session):
|
|
530
625
|
"""
|
531
626
|
super().validate()
|
532
627
|
if self.requires_second_factor:
|
533
|
-
raise SecondFactorRequiredError
|
628
|
+
raise SecondFactorRequiredError
|
629
|
+
|
630
|
+
async def refresh(self, request: Request):
|
631
|
+
"""
|
632
|
+
Refreshes session if within refresh date.
|
633
|
+
|
634
|
+
Args:
|
635
|
+
request (Request): Sanic request parameter.
|
636
|
+
|
637
|
+
Raises:
|
638
|
+
ExpiredError
|
639
|
+
|
640
|
+
Returns:
|
641
|
+
session
|
642
|
+
"""
|
643
|
+
if self.active and not is_expired(self.refresh_expiration_date):
|
644
|
+
await self.deactivate()
|
645
|
+
logging.info(
|
646
|
+
f"Client {get_ip(request)} has refreshed authentication session {self.id}."
|
647
|
+
)
|
648
|
+
return await self.new(request, self.bearer)
|
649
|
+
else:
|
650
|
+
raise ExpiredError
|
534
651
|
|
535
652
|
@classmethod
|
536
|
-
async def new(
|
537
|
-
|
653
|
+
async def new(
|
654
|
+
cls,
|
655
|
+
request: Request,
|
656
|
+
account: Account = None,
|
657
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
658
|
+
):
|
659
|
+
authentication_session = await cls.create(
|
538
660
|
**kwargs,
|
539
661
|
bearer=account,
|
540
662
|
ip=get_ip(request),
|
541
663
|
expiration_date=get_expiration_date(
|
542
|
-
|
664
|
+
config.AUTHENTICATION_SESSION_EXPIRATION
|
665
|
+
),
|
666
|
+
refresh_expiration_date=get_expiration_date(
|
667
|
+
config.AUTHENTICATION_REFRESH_EXPIRATION
|
543
668
|
),
|
544
669
|
)
|
670
|
+
return authentication_session
|
545
671
|
|
546
672
|
class Meta:
|
547
673
|
table = "authentication_session"
|
@@ -554,15 +680,15 @@ class Role(BaseModel):
|
|
554
680
|
Attributes:
|
555
681
|
name (str): Name of the role.
|
556
682
|
description (str): Description of the role.
|
557
|
-
permissions (str): Permissions of the role
|
683
|
+
permissions (list[str]): Permissions of the role, must in wildcard format (printer:query, dashboard:info,delete).
|
558
684
|
"""
|
559
685
|
|
560
686
|
name: str = fields.CharField(unique=True, max_length=255)
|
561
687
|
description: str = fields.CharField(max_length=255, null=True)
|
562
|
-
permissions: str = fields.
|
688
|
+
permissions: list[str] = fields.JSONField(null=True)
|
563
689
|
|
564
690
|
def validate(self) -> None:
|
565
|
-
raise NotImplementedError
|
691
|
+
raise NotImplementedError
|
566
692
|
|
567
693
|
@property
|
568
694
|
def json(self) -> dict:
|