sanic-security 1.11.6__py3-none-any.whl → 1.16.6__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 +111 -64
- sanic_security/configuration.py +42 -25
- sanic_security/exceptions.py +58 -24
- sanic_security/models.py +287 -159
- 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.6.dist-info/LICENSE +21 -0
- {sanic_security-1.11.6.dist-info → sanic_security-1.16.6.dist-info}/METADATA +685 -591
- sanic_security-1.16.6.dist-info/RECORD +17 -0
- {sanic_security-1.11.6.dist-info → sanic_security-1.16.6.dist-info}/WHEEL +2 -1
- sanic_security-1.16.6.dist-info/top_level.txt +1 -0
- sanic_security-1.11.6.dist-info/LICENSE +0 -661
- sanic_security-1.11.6.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,17 +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.email if isinstance(self.bearer, Account) else None,
|
244
|
-
"active": self.active,
|
245
|
-
}
|
246
|
-
|
247
318
|
def validate(self) -> None:
|
248
319
|
"""
|
249
320
|
Raises an error with respect to session state.
|
@@ -255,17 +326,14 @@ class Session(BaseModel):
|
|
255
326
|
"""
|
256
327
|
if self.deleted:
|
257
328
|
raise DeletedError("Session has been deleted.")
|
258
|
-
elif (
|
259
|
-
self.expiration_date
|
260
|
-
and datetime.datetime.now(datetime.timezone.utc) >= self.expiration_date
|
261
|
-
):
|
262
|
-
raise ExpiredError()
|
263
329
|
elif not self.active:
|
264
|
-
raise DeactivatedError
|
330
|
+
raise DeactivatedError
|
331
|
+
elif is_expired(self.expiration_date):
|
332
|
+
raise ExpiredError
|
265
333
|
|
266
|
-
async def deactivate(self):
|
334
|
+
async def deactivate(self) -> None:
|
267
335
|
"""
|
268
|
-
Renders session deactivated and unusable.
|
336
|
+
Renders session deactivated and therefor unusable.
|
269
337
|
|
270
338
|
Raises:
|
271
339
|
DeactivatedError
|
@@ -283,38 +351,55 @@ class Session(BaseModel):
|
|
283
351
|
Args:
|
284
352
|
response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
|
285
353
|
"""
|
286
|
-
payload = {
|
287
|
-
"id": self.id,
|
288
|
-
"date_created": str(self.date_created),
|
289
|
-
"expiration_date": str(self.expiration_date),
|
290
|
-
"ip": self.ip,
|
291
|
-
}
|
292
|
-
cookie = (
|
293
|
-
f"{security_config.SESSION_PREFIX}_{self.__class__.__name__.lower()[:7]}"
|
294
|
-
)
|
295
354
|
encoded_session = jwt.encode(
|
296
|
-
|
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,
|
297
364
|
)
|
298
|
-
if isinstance(encoded_session, bytes):
|
299
|
-
encoded_session = encoded_session.decode()
|
300
365
|
response.cookies.add_cookie(
|
301
|
-
|
302
|
-
encoded_session,
|
303
|
-
httponly=
|
304
|
-
samesite=
|
305
|
-
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,
|
306
374
|
)
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
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
|
311
396
|
|
312
397
|
@classmethod
|
313
398
|
async def new(
|
314
399
|
cls,
|
315
400
|
request: Request,
|
316
401
|
account: Account,
|
317
|
-
**kwargs: Union[int, str, bool, float, list, dict],
|
402
|
+
**kwargs: dict[str, Union[int, str, bool, float, list, dict]],
|
318
403
|
):
|
319
404
|
"""
|
320
405
|
Creates session with pre-set values.
|
@@ -322,12 +407,12 @@ class Session(BaseModel):
|
|
322
407
|
Args:
|
323
408
|
request (Request): Sanic request parameter.
|
324
409
|
account (Account): Account being associated to the session.
|
325
|
-
**kwargs (
|
410
|
+
**kwargs (Union[int, str, bool, float, list, dict]): Extra arguments applied during session creation.
|
326
411
|
|
327
412
|
Returns:
|
328
413
|
session
|
329
414
|
"""
|
330
|
-
raise NotImplementedError
|
415
|
+
raise NotImplementedError
|
331
416
|
|
332
417
|
@classmethod
|
333
418
|
async def get_associated(cls, account: Account):
|
@@ -335,7 +420,7 @@ class Session(BaseModel):
|
|
335
420
|
Retrieves sessions associated to an account.
|
336
421
|
|
337
422
|
Args:
|
338
|
-
account (
|
423
|
+
account (Account): Account associated with sessions being retrieved.
|
339
424
|
|
340
425
|
Returns:
|
341
426
|
sessions
|
@@ -343,7 +428,7 @@ class Session(BaseModel):
|
|
343
428
|
Raises:
|
344
429
|
NotFoundError
|
345
430
|
"""
|
346
|
-
sessions = await cls.filter(bearer=account).
|
431
|
+
sessions = await cls.filter(bearer=account, deleted=False).all()
|
347
432
|
if not sessions:
|
348
433
|
raise NotFoundError("No sessions associated to account were found.")
|
349
434
|
return sessions
|
@@ -351,7 +436,7 @@ class Session(BaseModel):
|
|
351
436
|
@classmethod
|
352
437
|
def decode_raw(cls, request: Request) -> dict:
|
353
438
|
"""
|
354
|
-
Decodes JWT token from client cookie into a python dict.
|
439
|
+
Decodes session JWT token from client cookie into a python dict.
|
355
440
|
|
356
441
|
Args:
|
357
442
|
request (Request): Sanic request parameter.
|
@@ -363,18 +448,16 @@ class Session(BaseModel):
|
|
363
448
|
JWTDecodeError
|
364
449
|
"""
|
365
450
|
cookie = request.cookies.get(
|
366
|
-
f"{
|
451
|
+
f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
|
367
452
|
)
|
368
453
|
try:
|
369
454
|
if not cookie:
|
370
|
-
raise JWTDecodeError
|
455
|
+
raise JWTDecodeError
|
371
456
|
else:
|
372
457
|
return jwt.decode(
|
373
458
|
cookie,
|
374
|
-
|
375
|
-
|
376
|
-
else security_config.PUBLIC_SECRET,
|
377
|
-
security_config.SESSION_ENCODING_ALGORITHM,
|
459
|
+
config.PUBLIC_SECRET or config.SECRET,
|
460
|
+
config.SESSION_ENCODING_ALGORITHM,
|
378
461
|
)
|
379
462
|
except DecodeError as e:
|
380
463
|
raise JWTDecodeError(str(e))
|
@@ -382,7 +465,7 @@ class Session(BaseModel):
|
|
382
465
|
@classmethod
|
383
466
|
async def decode(cls, request: Request):
|
384
467
|
"""
|
385
|
-
Decodes session JWT from client cookie
|
468
|
+
Decodes session JWT token from client cookie into a session model.
|
386
469
|
|
387
470
|
Args:
|
388
471
|
request (Request): Sanic request parameter.
|
@@ -399,6 +482,7 @@ class Session(BaseModel):
|
|
399
482
|
decoded_session = (
|
400
483
|
await cls.filter(id=decoded_raw["id"]).prefetch_related("bearer").get()
|
401
484
|
)
|
485
|
+
request.ctx.session = decoded_session
|
402
486
|
except DoesNotExist:
|
403
487
|
raise NotFoundError("Session could not be found.")
|
404
488
|
return decoded_session
|
@@ -409,66 +493,68 @@ class Session(BaseModel):
|
|
409
493
|
|
410
494
|
class VerificationSession(Session):
|
411
495
|
"""
|
412
|
-
Used for
|
496
|
+
Used for client verification challenges that require some form of code or key.
|
413
497
|
|
414
498
|
Attributes:
|
415
|
-
attempts (int): The amount of
|
416
|
-
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.
|
417
501
|
"""
|
418
502
|
|
419
503
|
attempts: int = fields.IntField(default=0)
|
420
|
-
code: str = fields.CharField(max_length=
|
421
|
-
|
422
|
-
@classmethod
|
423
|
-
async def new(cls, request: Request, account: Account, **kwargs):
|
424
|
-
raise NotImplementedError
|
504
|
+
code: str = fields.CharField(max_length=6, null=True)
|
425
505
|
|
426
|
-
async def check_code(self,
|
506
|
+
async def check_code(self, code: str) -> None:
|
427
507
|
"""
|
428
508
|
Checks if code passed is equivalent to the session code.
|
429
509
|
|
430
510
|
Args:
|
431
511
|
code (str): Code being cross-checked with session code.
|
432
|
-
request (Request): Sanic request parameter.
|
433
512
|
|
434
513
|
Raises:
|
435
514
|
ChallengeError
|
436
515
|
MaxedOutChallengeError
|
437
516
|
"""
|
438
|
-
if self.code != code.upper():
|
439
|
-
|
440
|
-
|
517
|
+
if not code or self.code != code.upper():
|
518
|
+
self.attempts += 1
|
519
|
+
if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
|
441
520
|
await self.save(update_fields=["attempts"])
|
442
521
|
raise ChallengeError(
|
443
522
|
"Your code does not match verification session code."
|
444
523
|
)
|
445
524
|
else:
|
446
|
-
|
447
|
-
f"Client ({get_ip(request)}) has maxed out on session challenge attempts"
|
448
|
-
)
|
449
|
-
raise MaxedOutChallengeError()
|
525
|
+
raise MaxedOutChallengeError
|
450
526
|
else:
|
451
|
-
self.
|
452
|
-
|
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
|
453
537
|
|
454
538
|
class Meta:
|
455
539
|
abstract = True
|
456
540
|
|
457
541
|
|
458
542
|
class TwoStepSession(VerificationSession):
|
459
|
-
"""
|
460
|
-
Validates a client using a code sent via email or text.
|
461
|
-
"""
|
543
|
+
"""Validates client using a code sent via email or text."""
|
462
544
|
|
463
545
|
@classmethod
|
464
|
-
async def new(
|
465
|
-
|
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(
|
466
553
|
**kwargs,
|
467
554
|
ip=get_ip(request),
|
468
555
|
bearer=account,
|
469
|
-
expiration_date=get_expiration_date(
|
470
|
-
|
471
|
-
),
|
556
|
+
expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
|
557
|
+
code=get_code(True),
|
472
558
|
)
|
473
559
|
|
474
560
|
class Meta:
|
@@ -476,31 +562,38 @@ class TwoStepSession(VerificationSession):
|
|
476
562
|
|
477
563
|
|
478
564
|
class CaptchaSession(VerificationSession):
|
479
|
-
"""
|
480
|
-
Validates a client with a captcha challenge.
|
481
|
-
"""
|
565
|
+
"""Validates client with a captcha challenge via image or audio."""
|
482
566
|
|
483
567
|
@classmethod
|
484
|
-
async def new(
|
485
|
-
|
568
|
+
async def new(
|
569
|
+
cls,
|
570
|
+
request: Request,
|
571
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
572
|
+
):
|
573
|
+
return await cls.create(
|
486
574
|
**kwargs,
|
487
575
|
ip=get_ip(request),
|
488
|
-
|
489
|
-
|
490
|
-
),
|
576
|
+
code=get_code(),
|
577
|
+
expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
|
491
578
|
)
|
492
579
|
|
493
|
-
def get_image(self) ->
|
580
|
+
def get_image(self) -> bytes:
|
494
581
|
"""
|
495
|
-
Retrieves captcha image
|
582
|
+
Retrieves captcha image data.
|
496
583
|
|
497
584
|
Returns:
|
498
585
|
captcha_image
|
499
586
|
"""
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
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))
|
504
597
|
|
505
598
|
class Meta:
|
506
599
|
table = "captcha_session"
|
@@ -511,10 +604,14 @@ class AuthenticationSession(Session):
|
|
511
604
|
Used to authenticate and identify a client.
|
512
605
|
|
513
606
|
Attributes:
|
607
|
+
refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
|
514
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.
|
515
610
|
"""
|
516
611
|
|
612
|
+
refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
|
517
613
|
requires_second_factor: bool = fields.BooleanField(default=False)
|
614
|
+
is_refresh: bool = False
|
518
615
|
|
519
616
|
def validate(self) -> None:
|
520
617
|
"""
|
@@ -528,18 +625,49 @@ class AuthenticationSession(Session):
|
|
528
625
|
"""
|
529
626
|
super().validate()
|
530
627
|
if self.requires_second_factor:
|
531
|
-
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
|
532
651
|
|
533
652
|
@classmethod
|
534
|
-
async def new(
|
535
|
-
|
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(
|
536
660
|
**kwargs,
|
537
661
|
bearer=account,
|
538
662
|
ip=get_ip(request),
|
539
663
|
expiration_date=get_expiration_date(
|
540
|
-
|
664
|
+
config.AUTHENTICATION_SESSION_EXPIRATION
|
665
|
+
),
|
666
|
+
refresh_expiration_date=get_expiration_date(
|
667
|
+
config.AUTHENTICATION_REFRESH_EXPIRATION
|
541
668
|
),
|
542
669
|
)
|
670
|
+
return authentication_session
|
543
671
|
|
544
672
|
class Meta:
|
545
673
|
table = "authentication_session"
|
@@ -552,15 +680,15 @@ class Role(BaseModel):
|
|
552
680
|
Attributes:
|
553
681
|
name (str): Name of the role.
|
554
682
|
description (str): Description of the role.
|
555
|
-
permissions (str): Permissions of the role
|
683
|
+
permissions (list[str]): Permissions of the role, must in wildcard format (printer:query, dashboard:info,delete).
|
556
684
|
"""
|
557
685
|
|
558
686
|
name: str = fields.CharField(unique=True, max_length=255)
|
559
687
|
description: str = fields.CharField(max_length=255, null=True)
|
560
|
-
permissions: str = fields.
|
688
|
+
permissions: list[str] = fields.JSONField(null=True)
|
561
689
|
|
562
690
|
def validate(self) -> None:
|
563
|
-
raise NotImplementedError
|
691
|
+
raise NotImplementedError
|
564
692
|
|
565
693
|
@property
|
566
694
|
def json(self) -> dict:
|