sanic-security 1.16.11__py3-none-any.whl → 1.17.0__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 -362
- sanic_security/authorization.py +240 -240
- sanic_security/configuration.py +125 -125
- sanic_security/exceptions.py +164 -216
- 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 -249
- {sanic_security-1.16.11.dist-info → sanic_security-1.17.0.dist-info}/METADATA +672 -672
- sanic_security-1.17.0.dist-info/RECORD +17 -0
- {sanic_security-1.16.11.dist-info → sanic_security-1.17.0.dist-info}/WHEEL +1 -1
- {sanic_security-1.16.11.dist-info → sanic_security-1.17.0.dist-info}/licenses/LICENSE +21 -21
- sanic_security-1.16.11.dist-info/RECORD +0 -17
- {sanic_security-1.16.11.dist-info → sanic_security-1.17.0.dist-info}/top_level.txt +0 -0
sanic_security/models.py
CHANGED
@@ -1,701 +1,721 @@
|
|
1
|
-
import base64
|
2
|
-
import datetime
|
3
|
-
import logging
|
4
|
-
import re
|
5
|
-
import uuid
|
6
|
-
from typing import Union
|
7
|
-
|
8
|
-
import jwt
|
9
|
-
from jwt import DecodeError
|
10
|
-
from sanic.request import Request
|
11
|
-
from sanic.response import HTTPResponse
|
12
|
-
from tortoise import fields, Model
|
13
|
-
from tortoise.exceptions import DoesNotExist, ValidationError
|
14
|
-
from tortoise.validators import RegexValidator
|
15
|
-
|
16
|
-
from sanic_security.configuration import config
|
17
|
-
from sanic_security.exceptions 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
|
-
)
|
26
|
-
|
27
|
-
"""
|
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.
|
47
|
-
"""
|
48
|
-
|
49
|
-
|
50
|
-
class BaseModel(Model):
|
51
|
-
"""
|
52
|
-
Base Sanic Security model that all other models derive from.
|
53
|
-
|
54
|
-
Attributes:
|
55
|
-
id (str): Primary key of model.
|
56
|
-
date_created (datetime): Time this model was created in the database.
|
57
|
-
date_updated (datetime): Time this model was updated in the database.
|
58
|
-
deleted (bool): Renders the model filterable without removing from the database.
|
59
|
-
"""
|
60
|
-
|
61
|
-
id: str = fields.CharField(
|
62
|
-
pk=True, max_length=36, default=lambda: str(uuid.uuid4())
|
63
|
-
)
|
64
|
-
date_created: datetime.datetime = fields.DatetimeField(auto_now_add=True)
|
65
|
-
date_updated: datetime.datetime = fields.DatetimeField(auto_now=True)
|
66
|
-
deleted: bool = fields.BooleanField(default=False)
|
67
|
-
|
68
|
-
def validate(self) -> None:
|
69
|
-
"""
|
70
|
-
Raises an error with respect to model's state.
|
71
|
-
|
72
|
-
Raises:
|
73
|
-
SecurityError
|
74
|
-
"""
|
75
|
-
raise NotImplementedError
|
76
|
-
|
77
|
-
@property
|
78
|
-
def json(self) -> dict:
|
79
|
-
"""
|
80
|
-
A JSON serializable dict to be used in a request or response.
|
81
|
-
|
82
|
-
Example:
|
83
|
-
Below is an example of this method returning a dict to be used for JSON serialization.
|
84
|
-
|
85
|
-
def json(self):
|
86
|
-
return {
|
87
|
-
'id': id,
|
88
|
-
'date_created': str(self.date_created),
|
89
|
-
'date_updated': str(self.date_updated),
|
90
|
-
'email': self.email,
|
91
|
-
'username': self.username,
|
92
|
-
'disabled': self.disabled,
|
93
|
-
'verified': self.verified
|
94
|
-
}
|
95
|
-
|
96
|
-
"""
|
97
|
-
raise NotImplementedError
|
98
|
-
|
99
|
-
class Meta:
|
100
|
-
abstract = True
|
101
|
-
|
102
|
-
|
103
|
-
class Account(BaseModel):
|
104
|
-
"""
|
105
|
-
Contains all identifiable user information.
|
106
|
-
|
107
|
-
Attributes:
|
108
|
-
username (str): Public identifier.
|
109
|
-
email (str): Private identifier and can be used for verification.
|
110
|
-
phone (str): Mobile phone number with country code included and can be used for verification. Can be null or empty.
|
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.
|
113
|
-
disabled (bool): Renders the account unusable but available.
|
114
|
-
verified (bool): Renders the account unusable until verified via two-step verification or other method.
|
115
|
-
roles (ManyToManyRelation[Role]): Roles associated with this account.
|
116
|
-
"""
|
117
|
-
|
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=
|
132
|
-
null=True,
|
133
|
-
validators=[
|
134
|
-
RegexValidator(r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", re.I)
|
135
|
-
],
|
136
|
-
)
|
137
|
-
password: str = fields.CharField(max_length=255)
|
138
|
-
oauth_id: str = fields.CharField(unique=True, null=True, max_length=255)
|
139
|
-
disabled: bool = fields.BooleanField(default=False)
|
140
|
-
verified: bool = fields.BooleanField(default=False)
|
141
|
-
roles: fields.ManyToManyRelation["Role"] = fields.ManyToManyField(
|
142
|
-
"models.Role", through="account_role"
|
143
|
-
)
|
144
|
-
|
145
|
-
def validate(self) -> None:
|
146
|
-
"""
|
147
|
-
Raises an error with respect to account state.
|
148
|
-
|
149
|
-
Raises:
|
150
|
-
DeletedError
|
151
|
-
UnverifiedError
|
152
|
-
DisabledError
|
153
|
-
"""
|
154
|
-
if self.deleted:
|
155
|
-
raise DeletedError("Account has been deleted.")
|
156
|
-
elif not self.verified:
|
157
|
-
raise UnverifiedError
|
158
|
-
elif self.disabled:
|
159
|
-
raise DisabledError
|
160
|
-
|
161
|
-
async def disable(self):
|
162
|
-
"""
|
163
|
-
Renders account unusable.
|
164
|
-
|
165
|
-
Raises:
|
166
|
-
DisabledError
|
167
|
-
"""
|
168
|
-
if self.disabled:
|
169
|
-
raise DisabledError("Account is already disabled.")
|
170
|
-
else:
|
171
|
-
self.disabled = True
|
172
|
-
await self.save(update_fields=["disabled"])
|
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
|
-
|
187
|
-
@staticmethod
|
188
|
-
async def get_via_email(email: str):
|
189
|
-
"""
|
190
|
-
Retrieve an account with an email.
|
191
|
-
|
192
|
-
Args:
|
193
|
-
email (str): Email associated to account being retrieved.
|
194
|
-
|
195
|
-
Returns:
|
196
|
-
account
|
197
|
-
|
198
|
-
Raises:
|
199
|
-
NotFoundError
|
200
|
-
"""
|
201
|
-
try:
|
202
|
-
return await Account.filter(email=email.lower(), deleted=False).get()
|
203
|
-
except (DoesNotExist, ValidationError):
|
204
|
-
raise NotFoundError("Account with this email does not exist.")
|
205
|
-
|
206
|
-
@staticmethod
|
207
|
-
async def get_via_username(username: str):
|
208
|
-
"""
|
209
|
-
Retrieve an account with a username.
|
210
|
-
|
211
|
-
Args:
|
212
|
-
username (str): Username associated to account being retrieved.
|
213
|
-
|
214
|
-
Returns:
|
215
|
-
account
|
216
|
-
|
217
|
-
Raises:
|
218
|
-
NotFoundError
|
219
|
-
"""
|
220
|
-
try:
|
221
|
-
return await Account.filter(username=username, deleted=False).get()
|
222
|
-
except (DoesNotExist, ValidationError):
|
223
|
-
raise NotFoundError("Account with this username does not exist.")
|
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
|
-
|
277
|
-
@staticmethod
|
278
|
-
async def get_via_phone(phone: str):
|
279
|
-
"""
|
280
|
-
Retrieve an account via a phone number.
|
281
|
-
|
282
|
-
Args:
|
283
|
-
phone (str): Phone number associated to account being retrieved.
|
284
|
-
|
285
|
-
Returns:
|
286
|
-
account
|
287
|
-
|
288
|
-
Raises:
|
289
|
-
NotFoundError
|
290
|
-
"""
|
291
|
-
try:
|
292
|
-
return await Account.filter(phone=phone, deleted=False).get()
|
293
|
-
except (DoesNotExist, ValidationError):
|
294
|
-
raise NotFoundError("Account with this phone number does not exist.")
|
295
|
-
|
296
|
-
|
297
|
-
class Session(BaseModel):
|
298
|
-
"""
|
299
|
-
Used for client identification and verification. Base session model that all session models derive from.
|
300
|
-
|
301
|
-
Attributes:
|
302
|
-
expiration_date (datetime): Date and time the session expires and can no longer be used.
|
303
|
-
active (bool): Determines if the session can be used.
|
304
|
-
ip (str): IP address of client instantiating session.
|
305
|
-
bearer (ForeignKeyRelation[Account]): Account associated with this session.
|
306
|
-
"""
|
307
|
-
|
308
|
-
expiration_date: datetime.datetime = fields.DatetimeField(null=True)
|
309
|
-
active: bool = fields.BooleanField(default=True)
|
310
|
-
ip: str = fields.CharField(max_length=16)
|
311
|
-
bearer: fields.ForeignKeyRelation["Account"] = fields.ForeignKeyField(
|
312
|
-
"models.Account", null=True
|
313
|
-
)
|
314
|
-
|
315
|
-
def __init__(self, **kwargs):
|
316
|
-
super().__init__(**kwargs)
|
317
|
-
|
318
|
-
def validate(self) -> None:
|
319
|
-
"""
|
320
|
-
Raises an error with respect to session state.
|
321
|
-
|
322
|
-
Raises:
|
323
|
-
DeletedError
|
324
|
-
ExpiredError
|
325
|
-
DeactivatedError
|
326
|
-
"""
|
327
|
-
if self.deleted:
|
328
|
-
raise DeletedError("Session has been deleted.")
|
329
|
-
elif not self.active:
|
330
|
-
raise DeactivatedError
|
331
|
-
elif is_expired(self.expiration_date):
|
332
|
-
raise ExpiredError
|
333
|
-
|
334
|
-
async def deactivate(self) -> None:
|
335
|
-
"""
|
336
|
-
Renders session deactivated and therefor unusable.
|
337
|
-
|
338
|
-
Raises:
|
339
|
-
DeactivatedError
|
340
|
-
"""
|
341
|
-
if self.active:
|
342
|
-
self.active = False
|
343
|
-
await self.save(update_fields=["active"])
|
344
|
-
else:
|
345
|
-
raise DeactivatedError("Session is already deactivated.", 403)
|
346
|
-
|
347
|
-
def encode(self, response: HTTPResponse) -> None:
|
348
|
-
"""
|
349
|
-
Transforms session into JWT and then is stored in a cookie.
|
350
|
-
|
351
|
-
Args:
|
352
|
-
response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
|
353
|
-
"""
|
354
|
-
encoded_session = jwt.encode(
|
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,
|
364
|
-
)
|
365
|
-
response.cookies.add_cookie(
|
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", self.expiration_date),
|
373
|
-
)
|
374
|
-
|
375
|
-
@property
|
376
|
-
def json(self) -> dict:
|
377
|
-
return {
|
378
|
-
"id": self.id,
|
379
|
-
"date_created": str(self.date_created),
|
380
|
-
"date_updated": str(self.date_updated),
|
381
|
-
"expiration_date": str(self.expiration_date),
|
382
|
-
"bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
|
383
|
-
"active": self.active,
|
384
|
-
}
|
385
|
-
|
386
|
-
@property
|
387
|
-
def anonymous(self) -> bool:
|
388
|
-
"""
|
389
|
-
Determines if an account is associated with session.
|
390
|
-
|
391
|
-
Returns:
|
392
|
-
anonymous
|
393
|
-
"""
|
394
|
-
return self.bearer is None
|
395
|
-
|
396
|
-
@classmethod
|
397
|
-
async def new(
|
398
|
-
cls,
|
399
|
-
request: Request,
|
400
|
-
account: Account,
|
401
|
-
**kwargs: dict[str, Union[int, str, bool, float, list, dict]],
|
402
|
-
):
|
403
|
-
"""
|
404
|
-
Creates session with pre-set values.
|
405
|
-
|
406
|
-
Args:
|
407
|
-
request (Request): Sanic request parameter.
|
408
|
-
account (Account): Account being associated to the session.
|
409
|
-
**kwargs (Union[int, str, bool, float, list, dict]): Extra arguments applied during session creation.
|
410
|
-
|
411
|
-
Returns:
|
412
|
-
session
|
413
|
-
"""
|
414
|
-
raise NotImplementedError
|
415
|
-
|
416
|
-
@classmethod
|
417
|
-
async def get_associated(cls, account: Account):
|
418
|
-
"""
|
419
|
-
Retrieves sessions associated to an account.
|
420
|
-
|
421
|
-
Args:
|
422
|
-
account (Account): Account associated with sessions being retrieved.
|
423
|
-
|
424
|
-
Returns:
|
425
|
-
sessions
|
426
|
-
|
427
|
-
Raises:
|
428
|
-
NotFoundError
|
429
|
-
"""
|
430
|
-
sessions = await cls.filter(bearer=account, deleted=False).all()
|
431
|
-
if not sessions:
|
432
|
-
raise NotFoundError("No sessions associated to account were found.")
|
433
|
-
return sessions
|
434
|
-
|
435
|
-
@classmethod
|
436
|
-
def decode_raw(cls, request: Request) -> dict:
|
437
|
-
"""
|
438
|
-
Decodes session JWT token from client cookie into a python dict.
|
439
|
-
|
440
|
-
Args:
|
441
|
-
request (Request): Sanic request parameter.
|
442
|
-
|
443
|
-
Returns:
|
444
|
-
session_dict
|
445
|
-
|
446
|
-
Raises:
|
447
|
-
JWTDecodeError
|
448
|
-
"""
|
449
|
-
cookie = request.cookies.get(
|
450
|
-
f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
|
451
|
-
)
|
452
|
-
try:
|
453
|
-
if not cookie:
|
454
|
-
raise JWTDecodeError
|
455
|
-
else:
|
456
|
-
return jwt.decode(
|
457
|
-
cookie,
|
458
|
-
config.PUBLIC_SECRET or config.SECRET,
|
459
|
-
config.SESSION_ENCODING_ALGORITHM,
|
460
|
-
)
|
461
|
-
except DecodeError as e:
|
462
|
-
raise JWTDecodeError(str(e))
|
463
|
-
|
464
|
-
@classmethod
|
465
|
-
async def decode(
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
cls
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
Raises
|
637
|
-
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
660
|
-
|
661
|
-
|
662
|
-
|
663
|
-
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
669
|
-
|
670
|
-
|
671
|
-
|
672
|
-
|
673
|
-
|
674
|
-
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
1
|
+
import base64
|
2
|
+
import datetime
|
3
|
+
import logging
|
4
|
+
import re
|
5
|
+
import uuid
|
6
|
+
from typing import Union
|
7
|
+
|
8
|
+
import jwt
|
9
|
+
from jwt import DecodeError
|
10
|
+
from sanic.request import Request
|
11
|
+
from sanic.response import HTTPResponse
|
12
|
+
from tortoise import fields, Model
|
13
|
+
from tortoise.exceptions import DoesNotExist, ValidationError
|
14
|
+
from tortoise.validators import RegexValidator
|
15
|
+
|
16
|
+
from sanic_security.configuration import config
|
17
|
+
from sanic_security.exceptions 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
|
+
)
|
26
|
+
|
27
|
+
"""
|
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.
|
47
|
+
"""
|
48
|
+
|
49
|
+
|
50
|
+
class BaseModel(Model):
|
51
|
+
"""
|
52
|
+
Base Sanic Security model that all other models derive from.
|
53
|
+
|
54
|
+
Attributes:
|
55
|
+
id (str): Primary key of model.
|
56
|
+
date_created (datetime): Time this model was created in the database.
|
57
|
+
date_updated (datetime): Time this model was updated in the database.
|
58
|
+
deleted (bool): Renders the model filterable without removing from the database.
|
59
|
+
"""
|
60
|
+
|
61
|
+
id: str = fields.CharField(
|
62
|
+
pk=True, max_length=36, default=lambda: str(uuid.uuid4())
|
63
|
+
)
|
64
|
+
date_created: datetime.datetime = fields.DatetimeField(auto_now_add=True)
|
65
|
+
date_updated: datetime.datetime = fields.DatetimeField(auto_now=True)
|
66
|
+
deleted: bool = fields.BooleanField(default=False)
|
67
|
+
|
68
|
+
def validate(self) -> None:
|
69
|
+
"""
|
70
|
+
Raises an error with respect to model's state.
|
71
|
+
|
72
|
+
Raises:
|
73
|
+
SecurityError
|
74
|
+
"""
|
75
|
+
raise NotImplementedError
|
76
|
+
|
77
|
+
@property
|
78
|
+
def json(self) -> dict:
|
79
|
+
"""
|
80
|
+
A JSON serializable dict to be used in a request or response.
|
81
|
+
|
82
|
+
Example:
|
83
|
+
Below is an example of this method returning a dict to be used for JSON serialization.
|
84
|
+
|
85
|
+
def json(self):
|
86
|
+
return {
|
87
|
+
'id': id,
|
88
|
+
'date_created': str(self.date_created),
|
89
|
+
'date_updated': str(self.date_updated),
|
90
|
+
'email': self.email,
|
91
|
+
'username': self.username,
|
92
|
+
'disabled': self.disabled,
|
93
|
+
'verified': self.verified
|
94
|
+
}
|
95
|
+
|
96
|
+
"""
|
97
|
+
raise NotImplementedError
|
98
|
+
|
99
|
+
class Meta:
|
100
|
+
abstract = True
|
101
|
+
|
102
|
+
|
103
|
+
class Account(BaseModel):
|
104
|
+
"""
|
105
|
+
Contains all identifiable user information.
|
106
|
+
|
107
|
+
Attributes:
|
108
|
+
username (str): Public identifier.
|
109
|
+
email (str): Private identifier and can be used for verification.
|
110
|
+
phone (str): Mobile phone number with country code included and can be used for verification. Can be null or empty.
|
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.
|
113
|
+
disabled (bool): Renders the account unusable but available.
|
114
|
+
verified (bool): Renders the account unusable until verified via two-step verification or other method.
|
115
|
+
roles (ManyToManyRelation[Role]): Roles associated with this account.
|
116
|
+
"""
|
117
|
+
|
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=20,
|
132
|
+
null=True,
|
133
|
+
validators=[
|
134
|
+
RegexValidator(r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", re.I)
|
135
|
+
],
|
136
|
+
)
|
137
|
+
password: str = fields.CharField(max_length=255)
|
138
|
+
oauth_id: str = fields.CharField(unique=True, null=True, max_length=255)
|
139
|
+
disabled: bool = fields.BooleanField(default=False)
|
140
|
+
verified: bool = fields.BooleanField(default=False)
|
141
|
+
roles: fields.ManyToManyRelation["Role"] = fields.ManyToManyField(
|
142
|
+
"models.Role", through="account_role"
|
143
|
+
)
|
144
|
+
|
145
|
+
def validate(self) -> None:
|
146
|
+
"""
|
147
|
+
Raises an error with respect to account state.
|
148
|
+
|
149
|
+
Raises:
|
150
|
+
DeletedError
|
151
|
+
UnverifiedError
|
152
|
+
DisabledError
|
153
|
+
"""
|
154
|
+
if self.deleted:
|
155
|
+
raise DeletedError("Account has been deleted.")
|
156
|
+
elif not self.verified:
|
157
|
+
raise UnverifiedError
|
158
|
+
elif self.disabled:
|
159
|
+
raise DisabledError
|
160
|
+
|
161
|
+
async def disable(self):
|
162
|
+
"""
|
163
|
+
Renders account unusable.
|
164
|
+
|
165
|
+
Raises:
|
166
|
+
DisabledError
|
167
|
+
"""
|
168
|
+
if self.disabled:
|
169
|
+
raise DisabledError("Account is already disabled.")
|
170
|
+
else:
|
171
|
+
self.disabled = True
|
172
|
+
await self.save(update_fields=["disabled"])
|
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
|
+
|
187
|
+
@staticmethod
|
188
|
+
async def get_via_email(email: str):
|
189
|
+
"""
|
190
|
+
Retrieve an account with an email.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
email (str): Email associated to account being retrieved.
|
194
|
+
|
195
|
+
Returns:
|
196
|
+
account
|
197
|
+
|
198
|
+
Raises:
|
199
|
+
NotFoundError
|
200
|
+
"""
|
201
|
+
try:
|
202
|
+
return await Account.filter(email=email.lower(), deleted=False).get()
|
203
|
+
except (DoesNotExist, ValidationError):
|
204
|
+
raise NotFoundError("Account with this email does not exist.")
|
205
|
+
|
206
|
+
@staticmethod
|
207
|
+
async def get_via_username(username: str):
|
208
|
+
"""
|
209
|
+
Retrieve an account with a username.
|
210
|
+
|
211
|
+
Args:
|
212
|
+
username (str): Username associated to account being retrieved.
|
213
|
+
|
214
|
+
Returns:
|
215
|
+
account
|
216
|
+
|
217
|
+
Raises:
|
218
|
+
NotFoundError
|
219
|
+
"""
|
220
|
+
try:
|
221
|
+
return await Account.filter(username=username, deleted=False).get()
|
222
|
+
except (DoesNotExist, ValidationError):
|
223
|
+
raise NotFoundError("Account with this username does not exist.")
|
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
|
+
|
277
|
+
@staticmethod
|
278
|
+
async def get_via_phone(phone: str):
|
279
|
+
"""
|
280
|
+
Retrieve an account via a phone number.
|
281
|
+
|
282
|
+
Args:
|
283
|
+
phone (str): Phone number associated to account being retrieved.
|
284
|
+
|
285
|
+
Returns:
|
286
|
+
account
|
287
|
+
|
288
|
+
Raises:
|
289
|
+
NotFoundError
|
290
|
+
"""
|
291
|
+
try:
|
292
|
+
return await Account.filter(phone=phone, deleted=False).get()
|
293
|
+
except (DoesNotExist, ValidationError):
|
294
|
+
raise NotFoundError("Account with this phone number does not exist.")
|
295
|
+
|
296
|
+
|
297
|
+
class Session(BaseModel):
|
298
|
+
"""
|
299
|
+
Used for client identification and verification. Base session model that all session models derive from.
|
300
|
+
|
301
|
+
Attributes:
|
302
|
+
expiration_date (datetime): Date and time the session expires and can no longer be used.
|
303
|
+
active (bool): Determines if the session can be used.
|
304
|
+
ip (str): IP address of client instantiating session.
|
305
|
+
bearer (ForeignKeyRelation[Account]): Account associated with this session.
|
306
|
+
"""
|
307
|
+
|
308
|
+
expiration_date: datetime.datetime = fields.DatetimeField(null=True)
|
309
|
+
active: bool = fields.BooleanField(default=True)
|
310
|
+
ip: str = fields.CharField(max_length=16)
|
311
|
+
bearer: fields.ForeignKeyRelation["Account"] = fields.ForeignKeyField(
|
312
|
+
"models.Account", null=True
|
313
|
+
)
|
314
|
+
|
315
|
+
def __init__(self, **kwargs):
|
316
|
+
super().__init__(**kwargs)
|
317
|
+
|
318
|
+
def validate(self) -> None:
|
319
|
+
"""
|
320
|
+
Raises an error with respect to session state.
|
321
|
+
|
322
|
+
Raises:
|
323
|
+
DeletedError
|
324
|
+
ExpiredError
|
325
|
+
DeactivatedError
|
326
|
+
"""
|
327
|
+
if self.deleted:
|
328
|
+
raise DeletedError("Session has been deleted.")
|
329
|
+
elif not self.active:
|
330
|
+
raise DeactivatedError
|
331
|
+
elif is_expired(self.expiration_date):
|
332
|
+
raise ExpiredError
|
333
|
+
|
334
|
+
async def deactivate(self) -> None:
|
335
|
+
"""
|
336
|
+
Renders session deactivated and therefor unusable.
|
337
|
+
|
338
|
+
Raises:
|
339
|
+
DeactivatedError
|
340
|
+
"""
|
341
|
+
if self.active:
|
342
|
+
self.active = False
|
343
|
+
await self.save(update_fields=["active"])
|
344
|
+
else:
|
345
|
+
raise DeactivatedError("Session is already deactivated.", 403)
|
346
|
+
|
347
|
+
def encode(self, response: HTTPResponse) -> None:
|
348
|
+
"""
|
349
|
+
Transforms session into JWT and then is stored in a cookie.
|
350
|
+
|
351
|
+
Args:
|
352
|
+
response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
|
353
|
+
"""
|
354
|
+
encoded_session = jwt.encode(
|
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,
|
364
|
+
)
|
365
|
+
response.cookies.add_cookie(
|
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", self.expiration_date),
|
373
|
+
)
|
374
|
+
|
375
|
+
@property
|
376
|
+
def json(self) -> dict:
|
377
|
+
return {
|
378
|
+
"id": self.id,
|
379
|
+
"date_created": str(self.date_created),
|
380
|
+
"date_updated": str(self.date_updated),
|
381
|
+
"expiration_date": str(self.expiration_date),
|
382
|
+
"bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
|
383
|
+
"active": self.active,
|
384
|
+
}
|
385
|
+
|
386
|
+
@property
|
387
|
+
def anonymous(self) -> bool:
|
388
|
+
"""
|
389
|
+
Determines if an account is associated with session.
|
390
|
+
|
391
|
+
Returns:
|
392
|
+
anonymous
|
393
|
+
"""
|
394
|
+
return self.bearer is None
|
395
|
+
|
396
|
+
@classmethod
|
397
|
+
async def new(
|
398
|
+
cls,
|
399
|
+
request: Request,
|
400
|
+
account: Account,
|
401
|
+
**kwargs: dict[str, Union[int, str, bool, float, list, dict]],
|
402
|
+
):
|
403
|
+
"""
|
404
|
+
Creates session with pre-set values.
|
405
|
+
|
406
|
+
Args:
|
407
|
+
request (Request): Sanic request parameter.
|
408
|
+
account (Account): Account being associated to the session.
|
409
|
+
**kwargs (Union[int, str, bool, float, list, dict]): Extra arguments applied during session creation.
|
410
|
+
|
411
|
+
Returns:
|
412
|
+
session
|
413
|
+
"""
|
414
|
+
raise NotImplementedError
|
415
|
+
|
416
|
+
@classmethod
|
417
|
+
async def get_associated(cls, account: Account):
|
418
|
+
"""
|
419
|
+
Retrieves sessions associated to an account.
|
420
|
+
|
421
|
+
Args:
|
422
|
+
account (Account): Account associated with sessions being retrieved.
|
423
|
+
|
424
|
+
Returns:
|
425
|
+
sessions
|
426
|
+
|
427
|
+
Raises:
|
428
|
+
NotFoundError
|
429
|
+
"""
|
430
|
+
sessions = await cls.filter(bearer=account, deleted=False).all()
|
431
|
+
if not sessions:
|
432
|
+
raise NotFoundError("No sessions associated to account were found.")
|
433
|
+
return sessions
|
434
|
+
|
435
|
+
@classmethod
|
436
|
+
def decode_raw(cls, request: Request) -> dict:
|
437
|
+
"""
|
438
|
+
Decodes session JWT token from client cookie into a python dict.
|
439
|
+
|
440
|
+
Args:
|
441
|
+
request (Request): Sanic request parameter.
|
442
|
+
|
443
|
+
Returns:
|
444
|
+
session_dict
|
445
|
+
|
446
|
+
Raises:
|
447
|
+
JWTDecodeError
|
448
|
+
"""
|
449
|
+
cookie = request.cookies.get(
|
450
|
+
f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
|
451
|
+
)
|
452
|
+
try:
|
453
|
+
if not cookie:
|
454
|
+
raise JWTDecodeError
|
455
|
+
else:
|
456
|
+
return jwt.decode(
|
457
|
+
cookie,
|
458
|
+
config.PUBLIC_SECRET or config.SECRET,
|
459
|
+
config.SESSION_ENCODING_ALGORITHM,
|
460
|
+
)
|
461
|
+
except DecodeError as e:
|
462
|
+
raise JWTDecodeError(str(e))
|
463
|
+
|
464
|
+
@classmethod
|
465
|
+
async def decode(
|
466
|
+
cls,
|
467
|
+
request: Request,
|
468
|
+
raw: dict = None,
|
469
|
+
**kwargs: Union[int, str, bool, float],
|
470
|
+
):
|
471
|
+
"""
|
472
|
+
Decodes session JWT token from client cookie into a session model.
|
473
|
+
|
474
|
+
Args:
|
475
|
+
request (Request): Sanic request parameter.
|
476
|
+
raw (Request): Decoded JWT token provided by the client, include only for optimization purposes.
|
477
|
+
**kwargs (Union[int, str, bool, float]): Extra filter arguments applied during session decoding.
|
478
|
+
|
479
|
+
|
480
|
+
Returns:
|
481
|
+
session
|
482
|
+
|
483
|
+
Raises:
|
484
|
+
JWTDecodeError
|
485
|
+
NotFoundError
|
486
|
+
"""
|
487
|
+
try:
|
488
|
+
decoded_raw = raw or cls.decode_raw(request)
|
489
|
+
decoded_session = (
|
490
|
+
await cls.filter(**kwargs, id=decoded_raw["id"], deleted=False)
|
491
|
+
.prefetch_related("bearer")
|
492
|
+
.get()
|
493
|
+
)
|
494
|
+
request.ctx.session = decoded_session
|
495
|
+
except DoesNotExist:
|
496
|
+
raise NotFoundError("Session could not be found.")
|
497
|
+
return decoded_session
|
498
|
+
|
499
|
+
class Meta:
|
500
|
+
abstract = True
|
501
|
+
|
502
|
+
|
503
|
+
class VerificationSession(Session):
|
504
|
+
"""
|
505
|
+
Used for client verification challenges that require some form of code or key.
|
506
|
+
|
507
|
+
Attributes:
|
508
|
+
attempts (int): The amount of times a user entered a code not equal to this verification sessions code.
|
509
|
+
code (str): A secret key that would be sent via email, text, etc.
|
510
|
+
"""
|
511
|
+
|
512
|
+
attempts: int = fields.IntField(default=0)
|
513
|
+
code: str = fields.CharField(max_length=6, null=True)
|
514
|
+
|
515
|
+
async def check_code(self, code: str) -> None:
|
516
|
+
"""
|
517
|
+
Checks if code passed is equivalent to the session code.
|
518
|
+
|
519
|
+
Args:
|
520
|
+
code (str): Code being cross-checked with session code.
|
521
|
+
|
522
|
+
Raises:
|
523
|
+
ChallengeError
|
524
|
+
MaxedOutChallengeError
|
525
|
+
"""
|
526
|
+
if not code or self.code != code.upper():
|
527
|
+
self.attempts += 1
|
528
|
+
if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
|
529
|
+
await self.save(update_fields=["attempts"])
|
530
|
+
raise ChallengeError(
|
531
|
+
"Your code does not match verification session code."
|
532
|
+
)
|
533
|
+
else:
|
534
|
+
raise MaxedOutChallengeError
|
535
|
+
else:
|
536
|
+
await self.deactivate()
|
537
|
+
|
538
|
+
@classmethod
|
539
|
+
async def new(
|
540
|
+
cls,
|
541
|
+
request: Request,
|
542
|
+
account: Account,
|
543
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
544
|
+
):
|
545
|
+
raise NotImplementedError
|
546
|
+
|
547
|
+
class Meta:
|
548
|
+
abstract = True
|
549
|
+
|
550
|
+
|
551
|
+
class TwoStepSession(VerificationSession):
|
552
|
+
"""
|
553
|
+
Validates client using a code sent via email or text.
|
554
|
+
|
555
|
+
Attributes:
|
556
|
+
tag (str): Label used to distinguish sessions for specific purposes.
|
557
|
+
"""
|
558
|
+
|
559
|
+
tag: str = fields.CharField(max_length=20)
|
560
|
+
|
561
|
+
@classmethod
|
562
|
+
async def new(
|
563
|
+
cls,
|
564
|
+
request: Request,
|
565
|
+
account: Account,
|
566
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
567
|
+
):
|
568
|
+
return await cls.create(
|
569
|
+
**kwargs,
|
570
|
+
ip=get_ip(request),
|
571
|
+
bearer=account,
|
572
|
+
expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
|
573
|
+
code=get_code(True),
|
574
|
+
)
|
575
|
+
|
576
|
+
class Meta:
|
577
|
+
table = "two_step_session"
|
578
|
+
|
579
|
+
|
580
|
+
class CaptchaSession(VerificationSession):
|
581
|
+
"""Validates client with a captcha challenge via image or audio."""
|
582
|
+
|
583
|
+
@classmethod
|
584
|
+
async def new(
|
585
|
+
cls,
|
586
|
+
request: Request,
|
587
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
588
|
+
):
|
589
|
+
return await cls.create(
|
590
|
+
**kwargs,
|
591
|
+
ip=get_ip(request),
|
592
|
+
code=get_code(),
|
593
|
+
expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
|
594
|
+
)
|
595
|
+
|
596
|
+
def get_image(self) -> bytes:
|
597
|
+
"""
|
598
|
+
Retrieves captcha image data.
|
599
|
+
|
600
|
+
Returns:
|
601
|
+
captcha_image
|
602
|
+
"""
|
603
|
+
return image_generator.generate(self.code, "jpeg").getvalue()
|
604
|
+
|
605
|
+
def get_audio(self) -> bytes:
|
606
|
+
"""
|
607
|
+
Retrieves captcha audio data.
|
608
|
+
|
609
|
+
Returns:
|
610
|
+
captcha_audio
|
611
|
+
"""
|
612
|
+
return bytes(audio_generator.generate(self.code))
|
613
|
+
|
614
|
+
class Meta:
|
615
|
+
table = "captcha_session"
|
616
|
+
|
617
|
+
|
618
|
+
class AuthenticationSession(Session):
|
619
|
+
"""
|
620
|
+
Used to authenticate and identify a client.
|
621
|
+
|
622
|
+
Attributes:
|
623
|
+
refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
|
624
|
+
requires_second_factor (bool): Determines if session requires a second factor.
|
625
|
+
user_agent (bool): Identifies client application, operating system, vendor, and/or version.
|
626
|
+
is_refresh (bool): Will only be true once when instantiated during the refresh of expired session.
|
627
|
+
"""
|
628
|
+
|
629
|
+
refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
|
630
|
+
requires_second_factor: bool = fields.BooleanField(default=False)
|
631
|
+
user_agent: str = fields.CharField(max_length=255, null=True)
|
632
|
+
is_refresh: bool = False
|
633
|
+
|
634
|
+
def validate(self) -> None:
|
635
|
+
"""
|
636
|
+
Raises an error with respect to session state.
|
637
|
+
|
638
|
+
Raises:
|
639
|
+
DeletedError
|
640
|
+
ExpiredError
|
641
|
+
DeactivatedError
|
642
|
+
SecondFactorRequiredError
|
643
|
+
"""
|
644
|
+
super().validate()
|
645
|
+
if self.requires_second_factor:
|
646
|
+
raise SecondFactorRequiredError
|
647
|
+
|
648
|
+
async def refresh(self, request: Request):
|
649
|
+
"""
|
650
|
+
Refreshes session if within refresh date.
|
651
|
+
|
652
|
+
Args:
|
653
|
+
request (Request): Sanic request parameter.
|
654
|
+
|
655
|
+
Raises:
|
656
|
+
ExpiredError
|
657
|
+
|
658
|
+
Returns:
|
659
|
+
session
|
660
|
+
"""
|
661
|
+
if self.active and not is_expired(self.refresh_expiration_date):
|
662
|
+
await self.deactivate()
|
663
|
+
logging.info(
|
664
|
+
f"Client {get_ip(request)} has refreshed authentication session {self.id}."
|
665
|
+
)
|
666
|
+
return await self.new(request, self.bearer)
|
667
|
+
else:
|
668
|
+
raise ExpiredError
|
669
|
+
|
670
|
+
@classmethod
|
671
|
+
async def new(
|
672
|
+
cls,
|
673
|
+
request: Request,
|
674
|
+
account: Account = None,
|
675
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
676
|
+
):
|
677
|
+
authentication_session = await cls.create(
|
678
|
+
**kwargs,
|
679
|
+
bearer=account,
|
680
|
+
ip=get_ip(request),
|
681
|
+
user_agent=request.headers.get("user-agent"),
|
682
|
+
expiration_date=get_expiration_date(
|
683
|
+
config.AUTHENTICATION_SESSION_EXPIRATION
|
684
|
+
),
|
685
|
+
refresh_expiration_date=get_expiration_date(
|
686
|
+
config.AUTHENTICATION_REFRESH_EXPIRATION
|
687
|
+
),
|
688
|
+
)
|
689
|
+
return authentication_session
|
690
|
+
|
691
|
+
class Meta:
|
692
|
+
table = "authentication_session"
|
693
|
+
|
694
|
+
|
695
|
+
class Role(BaseModel):
|
696
|
+
"""
|
697
|
+
Assigned to an account to authorize an action.
|
698
|
+
|
699
|
+
Attributes:
|
700
|
+
name (str): Name of the role.
|
701
|
+
description (str): Description of the role.
|
702
|
+
permissions (list[str]): Permissions of the role, must in wildcard format (printer:query, dashboard:info,delete).
|
703
|
+
"""
|
704
|
+
|
705
|
+
name: str = fields.CharField(unique=True, max_length=255)
|
706
|
+
description: str = fields.CharField(max_length=255, null=True)
|
707
|
+
permissions: list[str] = fields.JSONField(null=True)
|
708
|
+
|
709
|
+
def validate(self) -> None:
|
710
|
+
raise NotImplementedError
|
711
|
+
|
712
|
+
@property
|
713
|
+
def json(self) -> dict:
|
714
|
+
return {
|
715
|
+
"id": self.id,
|
716
|
+
"date_created": str(self.date_created),
|
717
|
+
"date_updated": str(self.date_updated),
|
718
|
+
"name": self.name,
|
719
|
+
"description": self.description,
|
720
|
+
"permissions": self.permissions,
|
721
|
+
}
|