sanic-security 1.16.12__py3-none-any.whl → 1.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sanic_security/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=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
- )
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(cls, request: Request):
466
- """
467
- Decodes session JWT token from client cookie into a session model.
468
-
469
- Args:
470
- request (Request): Sanic request parameter.
471
-
472
- Returns:
473
- session
474
-
475
- Raises:
476
- JWTDecodeError
477
- NotFoundError
478
- """
479
- try:
480
- decoded_raw = cls.decode_raw(request)
481
- decoded_session = (
482
- await cls.filter(id=decoded_raw["id"]).prefetch_related("bearer").get()
483
- )
484
- request.ctx.session = decoded_session
485
- except DoesNotExist:
486
- raise NotFoundError("Session could not be found.")
487
- return decoded_session
488
-
489
- class Meta:
490
- abstract = True
491
-
492
-
493
- class VerificationSession(Session):
494
- """
495
- Used for client verification challenges that require some form of code or key.
496
-
497
- Attributes:
498
- attempts (int): The amount of times a user entered a code not equal to this verification sessions code.
499
- code (str): A secret key that would be sent via email, text, etc.
500
- """
501
-
502
- attempts: int = fields.IntField(default=0)
503
- code: str = fields.CharField(max_length=6, null=True)
504
-
505
- async def check_code(self, code: str) -> None:
506
- """
507
- Checks if code passed is equivalent to the session code.
508
-
509
- Args:
510
- code (str): Code being cross-checked with session code.
511
-
512
- Raises:
513
- ChallengeError
514
- MaxedOutChallengeError
515
- """
516
- if not code or self.code != code.upper():
517
- self.attempts += 1
518
- if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
519
- await self.save(update_fields=["attempts"])
520
- raise ChallengeError(
521
- "Your code does not match verification session code."
522
- )
523
- else:
524
- raise MaxedOutChallengeError
525
- else:
526
- await self.deactivate()
527
-
528
- @classmethod
529
- async def new(
530
- cls,
531
- request: Request,
532
- account: Account,
533
- **kwargs: Union[int, str, bool, float, list, dict],
534
- ):
535
- raise NotImplementedError
536
-
537
- class Meta:
538
- abstract = True
539
-
540
-
541
- class TwoStepSession(VerificationSession):
542
- """Validates client using a code sent via email or text."""
543
-
544
- @classmethod
545
- async def new(
546
- cls,
547
- request: Request,
548
- account: Account,
549
- **kwargs: Union[int, str, bool, float, list, dict],
550
- ):
551
- return await cls.create(
552
- **kwargs,
553
- ip=get_ip(request),
554
- bearer=account,
555
- expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
556
- code=get_code(True),
557
- )
558
-
559
- class Meta:
560
- table = "two_step_session"
561
-
562
-
563
- class CaptchaSession(VerificationSession):
564
- """Validates client with a captcha challenge via image or audio."""
565
-
566
- @classmethod
567
- async def new(
568
- cls,
569
- request: Request,
570
- **kwargs: Union[int, str, bool, float, list, dict],
571
- ):
572
- return await cls.create(
573
- **kwargs,
574
- ip=get_ip(request),
575
- code=get_code(),
576
- expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
577
- )
578
-
579
- def get_image(self) -> bytes:
580
- """
581
- Retrieves captcha image data.
582
-
583
- Returns:
584
- captcha_image
585
- """
586
- return image_generator.generate(self.code, "jpeg").getvalue()
587
-
588
- def get_audio(self) -> bytes:
589
- """
590
- Retrieves captcha audio data.
591
-
592
- Returns:
593
- captcha_audio
594
- """
595
- return bytes(audio_generator.generate(self.code))
596
-
597
- class Meta:
598
- table = "captcha_session"
599
-
600
-
601
- class AuthenticationSession(Session):
602
- """
603
- Used to authenticate and identify a client.
604
-
605
- Attributes:
606
- refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
607
- requires_second_factor (bool): Determines if session requires a second factor.
608
- is_refresh (bool): Will only be true once when instantiated during the refresh of expired session.
609
- """
610
-
611
- refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
612
- requires_second_factor: bool = fields.BooleanField(default=False)
613
- is_refresh: bool = False
614
-
615
- def validate(self) -> None:
616
- """
617
- Raises an error with respect to session state.
618
-
619
- Raises:
620
- DeletedError
621
- ExpiredError
622
- DeactivatedError
623
- SecondFactorRequiredError
624
- """
625
- super().validate()
626
- if self.requires_second_factor:
627
- raise SecondFactorRequiredError
628
-
629
- async def refresh(self, request: Request):
630
- """
631
- Refreshes session if within refresh date.
632
-
633
- Args:
634
- request (Request): Sanic request parameter.
635
-
636
- Raises:
637
- ExpiredError
638
-
639
- Returns:
640
- session
641
- """
642
- if self.active and not is_expired(self.refresh_expiration_date):
643
- await self.deactivate()
644
- logging.info(
645
- f"Client {get_ip(request)} has refreshed authentication session {self.id}."
646
- )
647
- return await self.new(request, self.bearer)
648
- else:
649
- raise ExpiredError
650
-
651
- @classmethod
652
- async def new(
653
- cls,
654
- request: Request,
655
- account: Account = None,
656
- **kwargs: Union[int, str, bool, float, list, dict],
657
- ):
658
- authentication_session = await cls.create(
659
- **kwargs,
660
- bearer=account,
661
- ip=get_ip(request),
662
- expiration_date=get_expiration_date(
663
- config.AUTHENTICATION_SESSION_EXPIRATION
664
- ),
665
- refresh_expiration_date=get_expiration_date(
666
- config.AUTHENTICATION_REFRESH_EXPIRATION
667
- ),
668
- )
669
- return authentication_session
670
-
671
- class Meta:
672
- table = "authentication_session"
673
-
674
-
675
- class Role(BaseModel):
676
- """
677
- Assigned to an account to authorize an action.
678
-
679
- Attributes:
680
- name (str): Name of the role.
681
- description (str): Description of the role.
682
- permissions (list[str]): Permissions of the role, must in wildcard format (printer:query, dashboard:info,delete).
683
- """
684
-
685
- name: str = fields.CharField(unique=True, max_length=255)
686
- description: str = fields.CharField(max_length=255, null=True)
687
- permissions: list[str] = fields.JSONField(null=True)
688
-
689
- def validate(self) -> None:
690
- raise NotImplementedError
691
-
692
- @property
693
- def json(self) -> dict:
694
- return {
695
- "id": self.id,
696
- "date_created": str(self.date_created),
697
- "date_updated": str(self.date_updated),
698
- "name": self.name,
699
- "description": self.description,
700
- "permissions": self.permissions,
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
+ }