sanic-security 1.11.6__py3-none-any.whl → 1.16.6__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,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: