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

Sign up to get free protection for your applications and to get access to all the features.
sanic_security/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: