sanic-security 1.11.6__py3-none-any.whl → 1.16.6__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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:
|