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

Sign up to get free protection for your applications and to get access to all the features.
sanic_security/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,17 +315,6 @@ class Session(BaseModel):
233
315
  def __init__(self, **kwargs):
234
316
  super().__init__(**kwargs)
235
317
 
236
- @property
237
- def json(self) -> dict:
238
- return {
239
- "id": self.id,
240
- "date_created": str(self.date_created),
241
- "date_updated": str(self.date_updated),
242
- "expiration_date": str(self.expiration_date),
243
- "bearer": self.bearer.email if isinstance(self.bearer, Account) else None,
244
- "active": self.active,
245
- }
246
-
247
318
  def validate(self) -> None:
248
319
  """
249
320
  Raises an error with respect to session state.
@@ -255,17 +326,14 @@ class Session(BaseModel):
255
326
  """
256
327
  if self.deleted:
257
328
  raise DeletedError("Session has been deleted.")
258
- elif (
259
- self.expiration_date
260
- and datetime.datetime.now(datetime.timezone.utc) >= self.expiration_date
261
- ):
262
- raise ExpiredError()
263
329
  elif not self.active:
264
- raise DeactivatedError()
330
+ raise DeactivatedError
331
+ elif is_expired(self.expiration_date):
332
+ raise ExpiredError
265
333
 
266
- async def deactivate(self):
334
+ async def deactivate(self) -> None:
267
335
  """
268
- Renders session deactivated and unusable.
336
+ Renders session deactivated and therefor unusable.
269
337
 
270
338
  Raises:
271
339
  DeactivatedError
@@ -283,38 +351,55 @@ class Session(BaseModel):
283
351
  Args:
284
352
  response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
285
353
  """
286
- payload = {
287
- "id": self.id,
288
- "date_created": str(self.date_created),
289
- "expiration_date": str(self.expiration_date),
290
- "ip": self.ip,
291
- }
292
- cookie = (
293
- f"{security_config.SESSION_PREFIX}_{self.__class__.__name__.lower()[:7]}"
294
- )
295
354
  encoded_session = jwt.encode(
296
- 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,
297
364
  )
298
- if isinstance(encoded_session, bytes):
299
- encoded_session = encoded_session.decode()
300
365
  response.cookies.add_cookie(
301
- cookie,
302
- encoded_session,
303
- httponly=security_config.SESSION_HTTPONLY,
304
- samesite=security_config.SESSION_SAMESITE,
305
- 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,
306
374
  )
307
- if self.expiration_date:
308
- response.cookies.get_cookie(cookie).expires = self.expiration_date
309
- if security_config.SESSION_DOMAIN:
310
- 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
311
396
 
312
397
  @classmethod
313
398
  async def new(
314
399
  cls,
315
400
  request: Request,
316
401
  account: Account,
317
- **kwargs: Union[int, str, bool, float, list, dict],
402
+ **kwargs: dict[str, Union[int, str, bool, float, list, dict]],
318
403
  ):
319
404
  """
320
405
  Creates session with pre-set values.
@@ -322,12 +407,12 @@ class Session(BaseModel):
322
407
  Args:
323
408
  request (Request): Sanic request parameter.
324
409
  account (Account): Account being associated to the session.
325
- **kwargs (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.
326
411
 
327
412
  Returns:
328
413
  session
329
414
  """
330
- raise NotImplementedError()
415
+ raise NotImplementedError
331
416
 
332
417
  @classmethod
333
418
  async def get_associated(cls, account: Account):
@@ -335,7 +420,7 @@ class Session(BaseModel):
335
420
  Retrieves sessions associated to an account.
336
421
 
337
422
  Args:
338
- account (Request): Account associated with sessions being retrieved.
423
+ account (Account): Account associated with sessions being retrieved.
339
424
 
340
425
  Returns:
341
426
  sessions
@@ -343,7 +428,7 @@ class Session(BaseModel):
343
428
  Raises:
344
429
  NotFoundError
345
430
  """
346
- sessions = await cls.filter(bearer=account).prefetch_related("bearer").all()
431
+ sessions = await cls.filter(bearer=account, deleted=False).all()
347
432
  if not sessions:
348
433
  raise NotFoundError("No sessions associated to account were found.")
349
434
  return sessions
@@ -351,7 +436,7 @@ class Session(BaseModel):
351
436
  @classmethod
352
437
  def decode_raw(cls, request: Request) -> dict:
353
438
  """
354
- Decodes JWT token from client cookie into a python dict.
439
+ Decodes session JWT token from client cookie into a python dict.
355
440
 
356
441
  Args:
357
442
  request (Request): Sanic request parameter.
@@ -363,18 +448,16 @@ class Session(BaseModel):
363
448
  JWTDecodeError
364
449
  """
365
450
  cookie = request.cookies.get(
366
- f"{security_config.SESSION_PREFIX}_{cls.__name__.lower()[:7]}"
451
+ f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
367
452
  )
368
453
  try:
369
454
  if not cookie:
370
- raise JWTDecodeError("Session token not provided or expired.", 401)
455
+ raise JWTDecodeError
371
456
  else:
372
457
  return jwt.decode(
373
458
  cookie,
374
- security_config.SECRET
375
- if not security_config.PUBLIC_SECRET
376
- else security_config.PUBLIC_SECRET,
377
- security_config.SESSION_ENCODING_ALGORITHM,
459
+ config.PUBLIC_SECRET or config.SECRET,
460
+ config.SESSION_ENCODING_ALGORITHM,
378
461
  )
379
462
  except DecodeError as e:
380
463
  raise JWTDecodeError(str(e))
@@ -382,7 +465,7 @@ class Session(BaseModel):
382
465
  @classmethod
383
466
  async def decode(cls, request: Request):
384
467
  """
385
- Decodes session JWT from client cookie to a Sanic Security session.
468
+ Decodes session JWT token from client cookie into a session model.
386
469
 
387
470
  Args:
388
471
  request (Request): Sanic request parameter.
@@ -399,6 +482,7 @@ class Session(BaseModel):
399
482
  decoded_session = (
400
483
  await cls.filter(id=decoded_raw["id"]).prefetch_related("bearer").get()
401
484
  )
485
+ request.ctx.session = decoded_session
402
486
  except DoesNotExist:
403
487
  raise NotFoundError("Session could not be found.")
404
488
  return decoded_session
@@ -409,66 +493,68 @@ class Session(BaseModel):
409
493
 
410
494
  class VerificationSession(Session):
411
495
  """
412
- Used for 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.
413
497
 
414
498
  Attributes:
415
- attempts (int): The amount of incorrect times a user entered a code not equal to this verification sessions code.
416
- 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.
417
501
  """
418
502
 
419
503
  attempts: int = fields.IntField(default=0)
420
- code: str = fields.CharField(max_length=10, default=get_code, null=True)
421
-
422
- @classmethod
423
- async def new(cls, request: Request, account: Account, **kwargs):
424
- raise NotImplementedError
504
+ code: str = fields.CharField(max_length=6, null=True)
425
505
 
426
- async def check_code(self, request: Request, code: str) -> None:
506
+ async def check_code(self, code: str) -> None:
427
507
  """
428
508
  Checks if code passed is equivalent to the session code.
429
509
 
430
510
  Args:
431
511
  code (str): Code being cross-checked with session code.
432
- request (Request): Sanic request parameter.
433
512
 
434
513
  Raises:
435
514
  ChallengeError
436
515
  MaxedOutChallengeError
437
516
  """
438
- if self.code != code.upper():
439
- if self.attempts < security_config.MAX_CHALLENGE_ATTEMPTS:
440
- self.attempts += 1
517
+ if not code or self.code != code.upper():
518
+ self.attempts += 1
519
+ if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
441
520
  await self.save(update_fields=["attempts"])
442
521
  raise ChallengeError(
443
522
  "Your code does not match verification session code."
444
523
  )
445
524
  else:
446
- logger.warning(
447
- f"Client ({get_ip(request)}) has maxed out on session challenge attempts"
448
- )
449
- raise MaxedOutChallengeError()
525
+ raise MaxedOutChallengeError
450
526
  else:
451
- self.active = False
452
- 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
453
537
 
454
538
  class Meta:
455
539
  abstract = True
456
540
 
457
541
 
458
542
  class TwoStepSession(VerificationSession):
459
- """
460
- Validates a client using a code sent via email or text.
461
- """
543
+ """Validates client using a code sent via email or text."""
462
544
 
463
545
  @classmethod
464
- async def new(cls, request: Request, account: Account, **kwargs):
465
- 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(
466
553
  **kwargs,
467
554
  ip=get_ip(request),
468
555
  bearer=account,
469
- expiration_date=get_expiration_date(
470
- security_config.TWO_STEP_SESSION_EXPIRATION
471
- ),
556
+ expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
557
+ code=get_code(True),
472
558
  )
473
559
 
474
560
  class Meta:
@@ -476,31 +562,38 @@ class TwoStepSession(VerificationSession):
476
562
 
477
563
 
478
564
  class CaptchaSession(VerificationSession):
479
- """
480
- Validates a client with a captcha challenge.
481
- """
565
+ """Validates client with a captcha challenge via image or audio."""
482
566
 
483
567
  @classmethod
484
- async def new(cls, request: Request, **kwargs):
485
- 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(
486
574
  **kwargs,
487
575
  ip=get_ip(request),
488
- expiration_date=get_expiration_date(
489
- security_config.CAPTCHA_SESSION_EXPIRATION
490
- ),
576
+ code=get_code(),
577
+ expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
491
578
  )
492
579
 
493
- def get_image(self) -> HTTPResponse:
580
+ def get_image(self) -> bytes:
494
581
  """
495
- Retrieves captcha image file.
582
+ Retrieves captcha image data.
496
583
 
497
584
  Returns:
498
585
  captcha_image
499
586
  """
500
- image = ImageCaptcha(190, 90, fonts=[security_config.CAPTCHA_FONT])
501
- with BytesIO() as output:
502
- image.generate_image(self.code).save(output, format="JPEG")
503
- 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))
504
597
 
505
598
  class Meta:
506
599
  table = "captcha_session"
@@ -511,10 +604,14 @@ class AuthenticationSession(Session):
511
604
  Used to authenticate and identify a client.
512
605
 
513
606
  Attributes:
607
+ refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
514
608
  requires_second_factor (bool): Determines if session requires a second factor.
609
+ is_refresh (bool): Will only be true once when instantiated during the refresh of expired session.
515
610
  """
516
611
 
612
+ refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
517
613
  requires_second_factor: bool = fields.BooleanField(default=False)
614
+ is_refresh: bool = False
518
615
 
519
616
  def validate(self) -> None:
520
617
  """
@@ -528,18 +625,49 @@ class AuthenticationSession(Session):
528
625
  """
529
626
  super().validate()
530
627
  if self.requires_second_factor:
531
- raise SecondFactorRequiredError()
628
+ raise SecondFactorRequiredError
629
+
630
+ async def refresh(self, request: Request):
631
+ """
632
+ Refreshes session if within refresh date.
633
+
634
+ Args:
635
+ request (Request): Sanic request parameter.
636
+
637
+ Raises:
638
+ ExpiredError
639
+
640
+ Returns:
641
+ session
642
+ """
643
+ if self.active and not is_expired(self.refresh_expiration_date):
644
+ await self.deactivate()
645
+ logging.info(
646
+ f"Client {get_ip(request)} has refreshed authentication session {self.id}."
647
+ )
648
+ return await self.new(request, self.bearer)
649
+ else:
650
+ raise ExpiredError
532
651
 
533
652
  @classmethod
534
- async def new(cls, request: Request, account: Account, **kwargs):
535
- 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(
536
660
  **kwargs,
537
661
  bearer=account,
538
662
  ip=get_ip(request),
539
663
  expiration_date=get_expiration_date(
540
- security_config.AUTHENTICATION_SESSION_EXPIRATION
664
+ config.AUTHENTICATION_SESSION_EXPIRATION
665
+ ),
666
+ refresh_expiration_date=get_expiration_date(
667
+ config.AUTHENTICATION_REFRESH_EXPIRATION
541
668
  ),
542
669
  )
670
+ return authentication_session
543
671
 
544
672
  class Meta:
545
673
  table = "authentication_session"
@@ -552,15 +680,15 @@ class Role(BaseModel):
552
680
  Attributes:
553
681
  name (str): Name of the role.
554
682
  description (str): Description of the role.
555
- permissions (str): Permissions of the role. 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).
556
684
  """
557
685
 
558
686
  name: str = fields.CharField(unique=True, max_length=255)
559
687
  description: str = fields.CharField(max_length=255, null=True)
560
- permissions: str = fields.CharField(max_length=255, null=True)
688
+ permissions: list[str] = fields.JSONField(null=True)
561
689
 
562
690
  def validate(self) -> None:
563
- raise NotImplementedError()
691
+ raise NotImplementedError
564
692
 
565
693
  @property
566
694
  def json(self) -> dict: