sanic-security 1.11.7__py3-none-any.whl → 1.16.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sanic_security/models.py CHANGED
@@ -1,36 +1,49 @@
1
+ import base64
1
2
  import datetime
2
- from io import BytesIO
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, raw
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 as security_config
16
+ from sanic_security.configuration import config
15
17
  from sanic_security.exceptions import *
16
- from sanic_security.utils import get_ip, get_code, get_expiration_date
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
- An effective, simple, and async security library for the Sanic framework.
20
- Copyright (C) 2020-present Aidan Stewart
21
-
22
- This program is free software: you can redistribute it and/or modify
23
- it under the terms of the GNU Affero General Public License as published
24
- by the Free Software Foundation, either version 3 of the License, or
25
- (at your option) any later version.
26
-
27
- This program is distributed in the hope that it will be useful,
28
- but WITHOUT ANY WARRANTY; without even the implied warranty of
29
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
30
- GNU Affero General Public License for more details.
31
-
32
- You should have received a copy of the GNU Affero General Public License
33
- along with this program. If not, see <https://www.gnu.org/licenses/>.
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 (int): Primary key of model.
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: int = fields.IntField(pk=True)
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 HTTP request or response.
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. Must be hashed via Argon2.
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(unique=True, max_length=32)
103
- email: str = fields.CharField(unique=True, max_length=255)
104
- phone: str = fields.CharField(unique=True, max_length=14, null=True)
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
- account = await Account.filter(email=email, deleted=False).get()
170
- return account
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
- account = await Account.filter(username=username, deleted=False).get()
190
- return account
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 with a phone number.
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
- account = await Account.filter(phone=phone, deleted=False).get()
210
- return account
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 creating session.
304
+ ip (str): IP address of client instantiating session.
223
305
  bearer (ForeignKeyRelation[Account]): Account associated with this session.
224
306
  """
225
307
 
@@ -233,19 +315,6 @@ class Session(BaseModel):
233
315
  def __init__(self, **kwargs):
234
316
  super().__init__(**kwargs)
235
317
 
236
- @property
237
- def json(self) -> dict:
238
- return {
239
- "id": self.id,
240
- "date_created": str(self.date_created),
241
- "date_updated": str(self.date_updated),
242
- "expiration_date": str(self.expiration_date),
243
- "bearer": self.bearer.username
244
- if isinstance(self.bearer, Account)
245
- else None,
246
- "active": self.active,
247
- }
248
-
249
318
  def validate(self) -> None:
250
319
  """
251
320
  Raises an error with respect to session state.
@@ -257,17 +326,14 @@ class Session(BaseModel):
257
326
  """
258
327
  if self.deleted:
259
328
  raise DeletedError("Session has been deleted.")
260
- elif (
261
- self.expiration_date
262
- and datetime.datetime.now(datetime.timezone.utc) >= self.expiration_date
263
- ):
264
- raise ExpiredError()
265
329
  elif not self.active:
266
- raise DeactivatedError()
330
+ raise DeactivatedError
331
+ elif is_expired(self.expiration_date):
332
+ raise ExpiredError
267
333
 
268
- async def deactivate(self):
334
+ async def deactivate(self) -> None:
269
335
  """
270
- Renders session deactivated and unusable.
336
+ Renders session deactivated and therefor unusable.
271
337
 
272
338
  Raises:
273
339
  DeactivatedError
@@ -285,38 +351,55 @@ class Session(BaseModel):
285
351
  Args:
286
352
  response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
287
353
  """
288
- payload = {
289
- "id": self.id,
290
- "date_created": str(self.date_created),
291
- "expiration_date": str(self.expiration_date),
292
- "ip": self.ip,
293
- }
294
- cookie = (
295
- f"{security_config.SESSION_PREFIX}_{self.__class__.__name__.lower()[:7]}"
296
- )
297
354
  encoded_session = jwt.encode(
298
- payload, security_config.SECRET, security_config.SESSION_ENCODING_ALGORITHM
355
+ {
356
+ "id": self.id,
357
+ "date_created": str(self.date_created),
358
+ "expiration_date": str(self.expiration_date),
359
+ "bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
360
+ "ip": self.ip,
361
+ },
362
+ config.SECRET,
363
+ config.SESSION_ENCODING_ALGORITHM,
299
364
  )
300
- if isinstance(encoded_session, bytes):
301
- encoded_session = encoded_session.decode()
302
365
  response.cookies.add_cookie(
303
- cookie,
304
- encoded_session,
305
- httponly=security_config.SESSION_HTTPONLY,
306
- samesite=security_config.SESSION_SAMESITE,
307
- secure=security_config.SESSION_SECURE,
366
+ f"{config.SESSION_PREFIX}_{self.__class__.__name__[:7].lower()}",
367
+ str(encoded_session),
368
+ httponly=config.SESSION_HTTPONLY,
369
+ samesite=config.SESSION_SAMESITE,
370
+ secure=config.SESSION_SECURE,
371
+ domain=config.SESSION_DOMAIN,
372
+ expires=getattr(self, "refresh_expiration_date", None)
373
+ or self.expiration_date,
308
374
  )
309
- if self.expiration_date:
310
- response.cookies.get_cookie(cookie).expires = self.expiration_date
311
- if security_config.SESSION_DOMAIN:
312
- response.cookies.get_cookie(cookie).domain = security_config.SESSION_DOMAIN
375
+
376
+ @property
377
+ def json(self) -> dict:
378
+ return {
379
+ "id": self.id,
380
+ "date_created": str(self.date_created),
381
+ "date_updated": str(self.date_updated),
382
+ "expiration_date": str(self.expiration_date),
383
+ "bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
384
+ "active": self.active,
385
+ }
386
+
387
+ @property
388
+ def anonymous(self) -> bool:
389
+ """
390
+ Determines if an account is associated with session.
391
+
392
+ Returns:
393
+ anonymous
394
+ """
395
+ return self.bearer is None
313
396
 
314
397
  @classmethod
315
398
  async def new(
316
399
  cls,
317
400
  request: Request,
318
401
  account: Account,
319
- **kwargs: Union[int, str, bool, float, list, dict],
402
+ **kwargs: dict[str, Union[int, str, bool, float, list, dict]],
320
403
  ):
321
404
  """
322
405
  Creates session with pre-set values.
@@ -324,12 +407,12 @@ class Session(BaseModel):
324
407
  Args:
325
408
  request (Request): Sanic request parameter.
326
409
  account (Account): Account being associated to the session.
327
- **kwargs (dict[str, Union[int, str, bool, float, list, dict]]): Extra arguments applied during session creation.
410
+ **kwargs (Union[int, str, bool, float, list, dict]): Extra arguments applied during session creation.
328
411
 
329
412
  Returns:
330
413
  session
331
414
  """
332
- raise NotImplementedError()
415
+ raise NotImplementedError
333
416
 
334
417
  @classmethod
335
418
  async def get_associated(cls, account: Account):
@@ -337,7 +420,7 @@ class Session(BaseModel):
337
420
  Retrieves sessions associated to an account.
338
421
 
339
422
  Args:
340
- account (Request): Account associated with sessions being retrieved.
423
+ account (Account): Account associated with sessions being retrieved.
341
424
 
342
425
  Returns:
343
426
  sessions
@@ -345,7 +428,7 @@ class Session(BaseModel):
345
428
  Raises:
346
429
  NotFoundError
347
430
  """
348
- sessions = await cls.filter(bearer=account).prefetch_related("bearer").all()
431
+ sessions = await cls.filter(bearer=account, deleted=False).all()
349
432
  if not sessions:
350
433
  raise NotFoundError("No sessions associated to account were found.")
351
434
  return sessions
@@ -353,7 +436,7 @@ class Session(BaseModel):
353
436
  @classmethod
354
437
  def decode_raw(cls, request: Request) -> dict:
355
438
  """
356
- Decodes JWT token from client cookie into a python dict.
439
+ Decodes session JWT token from client cookie into a python dict.
357
440
 
358
441
  Args:
359
442
  request (Request): Sanic request parameter.
@@ -365,18 +448,16 @@ class Session(BaseModel):
365
448
  JWTDecodeError
366
449
  """
367
450
  cookie = request.cookies.get(
368
- f"{security_config.SESSION_PREFIX}_{cls.__name__.lower()[:7]}"
451
+ f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
369
452
  )
370
453
  try:
371
454
  if not cookie:
372
- raise JWTDecodeError("Session token not provided or expired.", 401)
455
+ raise JWTDecodeError
373
456
  else:
374
457
  return jwt.decode(
375
458
  cookie,
376
- security_config.SECRET
377
- if not security_config.PUBLIC_SECRET
378
- else security_config.PUBLIC_SECRET,
379
- security_config.SESSION_ENCODING_ALGORITHM,
459
+ config.PUBLIC_SECRET or config.SECRET,
460
+ config.SESSION_ENCODING_ALGORITHM,
380
461
  )
381
462
  except DecodeError as e:
382
463
  raise JWTDecodeError(str(e))
@@ -384,7 +465,7 @@ class Session(BaseModel):
384
465
  @classmethod
385
466
  async def decode(cls, request: Request):
386
467
  """
387
- Decodes session JWT from client cookie to a Sanic Security session.
468
+ Decodes session JWT token from client cookie into a session model.
388
469
 
389
470
  Args:
390
471
  request (Request): Sanic request parameter.
@@ -401,6 +482,7 @@ class Session(BaseModel):
401
482
  decoded_session = (
402
483
  await cls.filter(id=decoded_raw["id"]).prefetch_related("bearer").get()
403
484
  )
485
+ request.ctx.session = decoded_session
404
486
  except DoesNotExist:
405
487
  raise NotFoundError("Session could not be found.")
406
488
  return decoded_session
@@ -411,66 +493,68 @@ class Session(BaseModel):
411
493
 
412
494
  class VerificationSession(Session):
413
495
  """
414
- Used for a client verification method that requires some form of code, challenge, or key.
496
+ Used for client verification challenges that require some form of code or key.
415
497
 
416
498
  Attributes:
417
- attempts (int): The amount of incorrect times a user entered a code not equal to this verification sessions code.
418
- code (str): Used as a secret key that would be sent via email, text, etc to complete the verification challenge.
499
+ attempts (int): The amount of times a user entered a code not equal to this verification sessions code.
500
+ code (str): A secret key that would be sent via email, text, etc.
419
501
  """
420
502
 
421
503
  attempts: int = fields.IntField(default=0)
422
- code: str = fields.CharField(max_length=10, default=get_code, null=True)
423
-
424
- @classmethod
425
- async def new(cls, request: Request, account: Account, **kwargs):
426
- raise NotImplementedError
504
+ code: str = fields.CharField(max_length=6, null=True)
427
505
 
428
- async def check_code(self, request: Request, code: str) -> None:
506
+ async def check_code(self, code: str) -> None:
429
507
  """
430
508
  Checks if code passed is equivalent to the session code.
431
509
 
432
510
  Args:
433
511
  code (str): Code being cross-checked with session code.
434
- request (Request): Sanic request parameter.
435
512
 
436
513
  Raises:
437
514
  ChallengeError
438
515
  MaxedOutChallengeError
439
516
  """
440
- if self.code != code.upper():
441
- if self.attempts < security_config.MAX_CHALLENGE_ATTEMPTS:
442
- self.attempts += 1
517
+ if not code or self.code != code.upper():
518
+ self.attempts += 1
519
+ if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
443
520
  await self.save(update_fields=["attempts"])
444
521
  raise ChallengeError(
445
522
  "Your code does not match verification session code."
446
523
  )
447
524
  else:
448
- logger.warning(
449
- f"Client ({get_ip(request)}) has maxed out on session challenge attempts"
450
- )
451
- raise MaxedOutChallengeError()
525
+ raise MaxedOutChallengeError
452
526
  else:
453
- self.active = False
454
- await self.save(update_fields=["active"])
527
+ await self.deactivate()
528
+
529
+ @classmethod
530
+ async def new(
531
+ cls,
532
+ request: Request,
533
+ account: Account,
534
+ **kwargs: Union[int, str, bool, float, list, dict],
535
+ ):
536
+ raise NotImplementedError
455
537
 
456
538
  class Meta:
457
539
  abstract = True
458
540
 
459
541
 
460
542
  class TwoStepSession(VerificationSession):
461
- """
462
- Validates a client using a code sent via email or text.
463
- """
543
+ """Validates client using a code sent via email or text."""
464
544
 
465
545
  @classmethod
466
- async def new(cls, request: Request, account: Account, **kwargs):
467
- return await TwoStepSession.create(
546
+ async def new(
547
+ cls,
548
+ request: Request,
549
+ account: Account,
550
+ **kwargs: Union[int, str, bool, float, list, dict],
551
+ ):
552
+ return await cls.create(
468
553
  **kwargs,
469
554
  ip=get_ip(request),
470
555
  bearer=account,
471
- expiration_date=get_expiration_date(
472
- security_config.TWO_STEP_SESSION_EXPIRATION
473
- ),
556
+ expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
557
+ code=get_code(True),
474
558
  )
475
559
 
476
560
  class Meta:
@@ -478,31 +562,38 @@ class TwoStepSession(VerificationSession):
478
562
 
479
563
 
480
564
  class CaptchaSession(VerificationSession):
481
- """
482
- Validates a client with a captcha challenge.
483
- """
565
+ """Validates client with a captcha challenge via image or audio."""
484
566
 
485
567
  @classmethod
486
- async def new(cls, request: Request, **kwargs):
487
- return await CaptchaSession.create(
568
+ async def new(
569
+ cls,
570
+ request: Request,
571
+ **kwargs: Union[int, str, bool, float, list, dict],
572
+ ):
573
+ return await cls.create(
488
574
  **kwargs,
489
575
  ip=get_ip(request),
490
- expiration_date=get_expiration_date(
491
- security_config.CAPTCHA_SESSION_EXPIRATION
492
- ),
576
+ code=get_code(),
577
+ expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
493
578
  )
494
579
 
495
- def get_image(self) -> HTTPResponse:
580
+ def get_image(self) -> bytes:
496
581
  """
497
- Retrieves captcha image file.
582
+ Retrieves captcha image data.
498
583
 
499
584
  Returns:
500
585
  captcha_image
501
586
  """
502
- image = ImageCaptcha(190, 90, fonts=[security_config.CAPTCHA_FONT])
503
- with BytesIO() as output:
504
- image.generate_image(self.code).save(output, format="JPEG")
505
- return raw(output.getvalue(), content_type="image/jpeg")
587
+ return image_generator.generate(self.code, "jpeg").getvalue()
588
+
589
+ def get_audio(self) -> bytes:
590
+ """
591
+ Retrieves captcha audio data.
592
+
593
+ Returns:
594
+ captcha_audio
595
+ """
596
+ return bytes(audio_generator.generate(self.code))
506
597
 
507
598
  class Meta:
508
599
  table = "captcha_session"
@@ -513,10 +604,14 @@ class AuthenticationSession(Session):
513
604
  Used to authenticate and identify a client.
514
605
 
515
606
  Attributes:
607
+ refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
516
608
  requires_second_factor (bool): Determines if session requires a second factor.
609
+ is_refresh (bool): Will only be true once when instantiated during the refresh of expired session.
517
610
  """
518
611
 
612
+ refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
519
613
  requires_second_factor: bool = fields.BooleanField(default=False)
614
+ is_refresh: bool = False
520
615
 
521
616
  def validate(self) -> None:
522
617
  """
@@ -530,18 +625,49 @@ class AuthenticationSession(Session):
530
625
  """
531
626
  super().validate()
532
627
  if self.requires_second_factor:
533
- raise SecondFactorRequiredError()
628
+ raise SecondFactorRequiredError
629
+
630
+ async def refresh(self, request: Request):
631
+ """
632
+ Refreshes session if within refresh date.
633
+
634
+ Args:
635
+ request (Request): Sanic request parameter.
636
+
637
+ Raises:
638
+ ExpiredError
639
+
640
+ Returns:
641
+ session
642
+ """
643
+ if self.active and not is_expired(self.refresh_expiration_date):
644
+ await self.deactivate()
645
+ logging.info(
646
+ f"Client {get_ip(request)} has refreshed authentication session {self.id}."
647
+ )
648
+ return await self.new(request, self.bearer)
649
+ else:
650
+ raise ExpiredError
534
651
 
535
652
  @classmethod
536
- async def new(cls, request: Request, account: Account, **kwargs):
537
- return await AuthenticationSession.create(
653
+ async def new(
654
+ cls,
655
+ request: Request,
656
+ account: Account = None,
657
+ **kwargs: Union[int, str, bool, float, list, dict],
658
+ ):
659
+ authentication_session = await cls.create(
538
660
  **kwargs,
539
661
  bearer=account,
540
662
  ip=get_ip(request),
541
663
  expiration_date=get_expiration_date(
542
- security_config.AUTHENTICATION_SESSION_EXPIRATION
664
+ config.AUTHENTICATION_SESSION_EXPIRATION
665
+ ),
666
+ refresh_expiration_date=get_expiration_date(
667
+ config.AUTHENTICATION_REFRESH_EXPIRATION
543
668
  ),
544
669
  )
670
+ return authentication_session
545
671
 
546
672
  class Meta:
547
673
  table = "authentication_session"
@@ -554,15 +680,15 @@ class Role(BaseModel):
554
680
  Attributes:
555
681
  name (str): Name of the role.
556
682
  description (str): Description of the role.
557
- permissions (str): Permissions of the role. Must be separated via comma and in wildcard format (printer:query, printer:query,delete).
683
+ permissions (list[str]): Permissions of the role, must in wildcard format (printer:query, dashboard:info,delete).
558
684
  """
559
685
 
560
686
  name: str = fields.CharField(unique=True, max_length=255)
561
687
  description: str = fields.CharField(max_length=255, null=True)
562
- permissions: str = fields.CharField(max_length=255, null=True)
688
+ permissions: list[str] = fields.JSONField(null=True)
563
689
 
564
690
  def validate(self) -> None:
565
- raise NotImplementedError()
691
+ raise NotImplementedError
566
692
 
567
693
  @property
568
694
  def json(self) -> dict: