sanic-security 1.15.2__py3-none-any.whl → 1.16.0__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/authentication.py +18 -23
- sanic_security/authorization.py +44 -18
- sanic_security/configuration.py +9 -0
- sanic_security/exceptions.py +5 -3
- sanic_security/models.py +73 -70
- sanic_security/oauth.py +239 -0
- sanic_security/test/server.py +75 -28
- sanic_security/test/tests.py +25 -10
- sanic_security/utils.py +11 -7
- sanic_security/verification.py +1 -4
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/METADATA +133 -47
- sanic_security-1.16.0.dist-info/RECORD +17 -0
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/WHEEL +1 -1
- sanic_security-1.15.2.dist-info/RECORD +0 -16
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/LICENSE +0 -0
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/top_level.txt +0 -0
sanic_security/authentication.py
CHANGED
@@ -2,13 +2,13 @@ import functools
|
|
2
2
|
import re
|
3
3
|
import warnings
|
4
4
|
|
5
|
-
from argon2.exceptions import
|
5
|
+
from argon2.exceptions import VerificationError, InvalidHashError
|
6
6
|
from sanic import Sanic
|
7
7
|
from sanic.log import logger
|
8
8
|
from sanic.request import Request
|
9
9
|
from tortoise.exceptions import DoesNotExist, ValidationError, IntegrityError
|
10
10
|
|
11
|
-
from sanic_security.configuration import config
|
11
|
+
from sanic_security.configuration import config, DEFAULT_CONFIG
|
12
12
|
from sanic_security.exceptions import (
|
13
13
|
CredentialsError,
|
14
14
|
DeactivatedError,
|
@@ -74,7 +74,7 @@ async def register(
|
|
74
74
|
return account
|
75
75
|
except ValidationError as e:
|
76
76
|
raise CredentialsError(
|
77
|
-
"Username must be 3-32 characters long
|
77
|
+
"Username must be 3-32 characters long."
|
78
78
|
if "username" in e.args[0]
|
79
79
|
else "Invalid email or phone number."
|
80
80
|
)
|
@@ -128,7 +128,7 @@ async def login(
|
|
128
128
|
f"Client {get_ip(request)} has logged in with authentication session {authentication_session.id}."
|
129
129
|
)
|
130
130
|
return authentication_session
|
131
|
-
except
|
131
|
+
except (VerificationError, InvalidHashError):
|
132
132
|
logger.warning(
|
133
133
|
f"Client {get_ip(request)} has failed to log into account {account.id}."
|
134
134
|
)
|
@@ -247,6 +247,7 @@ def requires_authentication(arg=None):
|
|
247
247
|
DeactivatedError
|
248
248
|
UnverifiedError
|
249
249
|
DisabledError
|
250
|
+
SecondFactorRequiredError
|
250
251
|
ExpiredError
|
251
252
|
"""
|
252
253
|
|
@@ -282,41 +283,37 @@ def validate_password(password: str) -> str:
|
|
282
283
|
return password
|
283
284
|
|
284
285
|
|
285
|
-
def initialize_security(app: Sanic, create_root=True) -> None:
|
286
|
+
def initialize_security(app: Sanic, create_root: bool = True) -> None:
|
286
287
|
"""
|
287
288
|
Audits configuration, creates root administrator account, and attaches refresh encoder middleware.
|
288
289
|
|
289
290
|
Args:
|
290
|
-
app (Sanic):
|
291
|
+
app (Sanic): Sanic application instance.
|
291
292
|
create_root (bool): Determines root account creation on initialization.
|
292
293
|
"""
|
293
294
|
|
294
295
|
@app.listener("before_server_start")
|
295
296
|
async def audit_configuration(app, loop):
|
296
|
-
if
|
297
|
+
if config.SECRET == DEFAULT_CONFIG["SECRET"]:
|
297
298
|
warnings.warn("Secret should be changed from default.", AuditWarning, 2)
|
298
|
-
if not
|
299
|
+
if not config.SESSION_HTTPONLY:
|
299
300
|
warnings.warn("HttpOnly should be enabled.", AuditWarning, 2)
|
300
|
-
if not
|
301
|
+
if not config.SESSION_SECURE:
|
301
302
|
warnings.warn("Secure should be enabled.", AuditWarning, 2)
|
302
|
-
if (
|
303
|
-
not security_config.SESSION_SAMESITE
|
304
|
-
or security_config.SESSION_SAMESITE.lower() == "none"
|
305
|
-
):
|
303
|
+
if not config.SESSION_SAMESITE or config.SESSION_SAMESITE.lower() == "none":
|
306
304
|
warnings.warn("SameSite should not be none.", AuditWarning, 2)
|
307
|
-
if not
|
305
|
+
if not config.SESSION_DOMAIN:
|
308
306
|
warnings.warn("Domain should not be none.", AuditWarning, 2)
|
309
307
|
if (
|
310
308
|
create_root
|
311
|
-
and
|
312
|
-
== DEFAULT_CONFIG["INITIAL_ADMIN_EMAIL"]
|
309
|
+
and config.INITIAL_ADMIN_EMAIL == DEFAULT_CONFIG["INITIAL_ADMIN_EMAIL"]
|
313
310
|
):
|
314
311
|
warnings.warn(
|
315
312
|
"Initial admin email should be changed from default.", AuditWarning, 2
|
316
313
|
)
|
317
314
|
if (
|
318
315
|
create_root
|
319
|
-
and
|
316
|
+
and config.INITIAL_ADMIN_PASSWORD
|
320
317
|
== DEFAULT_CONFIG["INITIAL_ADMIN_PASSWORD"]
|
321
318
|
):
|
322
319
|
warnings.warn(
|
@@ -334,13 +331,11 @@ def initialize_security(app: Sanic, create_root=True) -> None:
|
|
334
331
|
except DoesNotExist:
|
335
332
|
role = await Role.create(
|
336
333
|
description="Has administrator abilities, assign sparingly.",
|
337
|
-
permissions="*:*",
|
334
|
+
permissions=["*:*"],
|
338
335
|
name="Root",
|
339
336
|
)
|
340
337
|
try:
|
341
|
-
account = await Account.filter(
|
342
|
-
email=security_config.INITIAL_ADMIN_EMAIL
|
343
|
-
).get()
|
338
|
+
account = await Account.filter(email=config.INITIAL_ADMIN_EMAIL).get()
|
344
339
|
await account.fetch_related("roles")
|
345
340
|
if role not in account.roles:
|
346
341
|
await account.roles.add(role)
|
@@ -348,8 +343,8 @@ def initialize_security(app: Sanic, create_root=True) -> None:
|
|
348
343
|
except DoesNotExist:
|
349
344
|
account = await Account.create(
|
350
345
|
username="Root",
|
351
|
-
email=
|
352
|
-
password=password_hasher.hash(
|
346
|
+
email=config.INITIAL_ADMIN_EMAIL,
|
347
|
+
password=password_hasher.hash(config.INITIAL_ADMIN_PASSWORD),
|
353
348
|
verified=True,
|
354
349
|
)
|
355
350
|
await account.roles.add(role)
|
sanic_security/authorization.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
import functools
|
2
|
-
from fnmatch import fnmatch
|
3
2
|
|
4
3
|
from sanic.log import logger
|
5
4
|
from sanic.request import Request
|
@@ -65,18 +64,39 @@ async def check_permissions(
|
|
65
64
|
raise AnonymousError
|
66
65
|
roles = await authentication_session.bearer.roles.filter(deleted=False).all()
|
67
66
|
for role in roles:
|
68
|
-
for
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
return authentication_session
|
67
|
+
for role_permission in role.permissions:
|
68
|
+
for required_permission in required_permissions:
|
69
|
+
if check_wildcard(role_permission, required_permission):
|
70
|
+
request.ctx.session = authentication_session
|
71
|
+
return authentication_session
|
74
72
|
logger.warning(
|
75
73
|
f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action."
|
76
74
|
)
|
77
75
|
raise AuthorizationError("Insufficient permissions required for this action.")
|
78
76
|
|
79
77
|
|
78
|
+
def check_wildcard(wildcard: str, pattern: str):
|
79
|
+
"""
|
80
|
+
Evaluates if the input matches the pattern considering wildcards (`*`) and comma-separated options in the pattern.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
wildcard (str): A wildcard string (e.g., "a:b:c").
|
84
|
+
pattern (str): A wildcard pattern optional (`*`) or comma-separated values to match against (e.g., "a:b,c:*").
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
is_match
|
88
|
+
"""
|
89
|
+
wildcard_parts = [set(part.split(",")) for part in wildcard.split(":")]
|
90
|
+
pattern_parts = [set(part.split(",")) for part in pattern.split(":")]
|
91
|
+
for i, pattern_part in enumerate(pattern_parts):
|
92
|
+
if i >= len(wildcard_parts):
|
93
|
+
return False
|
94
|
+
wildcard_part = wildcard_parts[i]
|
95
|
+
if "*" not in wildcard_part and not wildcard_part.issuperset(pattern_part):
|
96
|
+
return False
|
97
|
+
return all("*" in part for part in wildcard_parts[len(pattern_parts) :])
|
98
|
+
|
99
|
+
|
80
100
|
async def check_roles(request: Request, *required_roles: str) -> AuthenticationSession:
|
81
101
|
"""
|
82
102
|
Authenticates client and determines if the account has sufficient roles for an action.
|
@@ -105,19 +125,25 @@ async def check_roles(request: Request, *required_roles: str) -> AuthenticationS
|
|
105
125
|
f"Client {get_ip(request)} attempted an unauthorized action anonymously."
|
106
126
|
)
|
107
127
|
raise AnonymousError
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
128
|
+
if set(required_roles) & {
|
129
|
+
role.name
|
130
|
+
for role in await authentication_session.bearer.roles.filter(
|
131
|
+
deleted=False
|
132
|
+
).all()
|
133
|
+
}:
|
134
|
+
request.ctx.session = authentication_session
|
135
|
+
return authentication_session
|
113
136
|
logger.warning(
|
114
|
-
f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action
|
137
|
+
f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action"
|
115
138
|
)
|
116
|
-
raise AuthorizationError("Insufficient roles required for this action
|
139
|
+
raise AuthorizationError("Insufficient roles required for this action")
|
117
140
|
|
118
141
|
|
119
142
|
async def assign_role(
|
120
|
-
name: str,
|
143
|
+
name: str,
|
144
|
+
account: Account,
|
145
|
+
description: str = None,
|
146
|
+
*permissions: str,
|
121
147
|
) -> Role:
|
122
148
|
"""
|
123
149
|
Easy account role assignment. Role being assigned to an account will be created if it doesn't exist.
|
@@ -125,8 +151,8 @@ async def assign_role(
|
|
125
151
|
Args:
|
126
152
|
name (str): The name of the role associated with the account.
|
127
153
|
account (Account): The account associated with the created role.
|
128
|
-
permissions (str): The permissions of the role associated with the account. Permissions must be separated via ", " and in wildcard format.
|
129
154
|
description (str): The description of the role associated with the account.
|
155
|
+
*permissions (Tuple[str, ...]): The permissions of the role associated with the account, must be in wildcard format.
|
130
156
|
"""
|
131
157
|
try:
|
132
158
|
role = await Role.filter(name=name).get()
|
@@ -140,7 +166,7 @@ async def assign_role(
|
|
140
166
|
return role
|
141
167
|
|
142
168
|
|
143
|
-
def
|
169
|
+
def requires_permission(*required_permissions: str):
|
144
170
|
"""
|
145
171
|
Authenticates client and determines if the account has sufficient permissions for an action.
|
146
172
|
|
@@ -178,7 +204,7 @@ def require_permissions(*required_permissions: str):
|
|
178
204
|
return decorator
|
179
205
|
|
180
206
|
|
181
|
-
def
|
207
|
+
def requires_role(*required_roles: str):
|
182
208
|
"""
|
183
209
|
Authenticates client and determines if the account has sufficient roles for an action.
|
184
210
|
|
sanic_security/configuration.py
CHANGED
@@ -27,6 +27,9 @@ SOFTWARE.
|
|
27
27
|
DEFAULT_CONFIG = {
|
28
28
|
"SECRET": "This is a big secret. Shhhhh",
|
29
29
|
"PUBLIC_SECRET": None,
|
30
|
+
"OAUTH_CLIENT": None,
|
31
|
+
"OAUTH_SECRET": None,
|
32
|
+
"OAUTH_REDIRECT": None,
|
30
33
|
"SESSION_SAMESITE": "Strict",
|
31
34
|
"SESSION_SECURE": True,
|
32
35
|
"SESSION_HTTPONLY": True,
|
@@ -54,6 +57,9 @@ class Config(dict):
|
|
54
57
|
Attributes:
|
55
58
|
SECRET (str): The secret used by the hashing algorithm for generating and signing JWTs. This should be a string unique to your application. Keep it safe.
|
56
59
|
PUBLIC_SECRET (str): The secret used for verifying and decoding JWTs and can be publicly shared. This should be a string unique to your application.
|
60
|
+
OAUTH_CLIENT (str): The client ID provided by the OAuth provider, this is used to identify the application making the OAuth request.
|
61
|
+
OAUTH_SECRET (str): The client secret provided by the OAuth provider, this is used in conjunction with the client ID to authenticate the application.
|
62
|
+
OAUTH_REDIRECT (str): The redirect URI registered with the OAuth provider, This is the URI where the user will be redirected after a successful authentication.
|
57
63
|
SESSION_SAMESITE (str): The SameSite attribute of session cookies.
|
58
64
|
SESSION_SECURE (bool): The Secure attribute of session cookies.
|
59
65
|
SESSION_HTTPONLY (bool): The HttpOnly attribute of session cookies. HIGHLY recommended that you do not turn this off, unless you know what you are doing.
|
@@ -75,6 +81,9 @@ class Config(dict):
|
|
75
81
|
|
76
82
|
SECRET: str
|
77
83
|
PUBLIC_SECRET: str
|
84
|
+
OAUTH_CLIENT: str
|
85
|
+
OAUTH_SECRET: str
|
86
|
+
OAUTH_REDIRECT: str
|
78
87
|
SESSION_SAMESITE: str
|
79
88
|
SESSION_SECURE: bool
|
80
89
|
SESSION_HTTPONLY: bool
|
sanic_security/exceptions.py
CHANGED
@@ -119,7 +119,9 @@ class JWTDecodeError(SessionError):
|
|
119
119
|
Raised when client JWT is invalid.
|
120
120
|
"""
|
121
121
|
|
122
|
-
def __init__(
|
122
|
+
def __init__(
|
123
|
+
self, message="Session token invalid, not provided, or expired.", code=400
|
124
|
+
):
|
123
125
|
super().__init__(message, code)
|
124
126
|
|
125
127
|
|
@@ -141,8 +143,8 @@ class ExpiredError(SessionError):
|
|
141
143
|
Raised when session has expired.
|
142
144
|
"""
|
143
145
|
|
144
|
-
def __init__(self):
|
145
|
-
super().__init__(
|
146
|
+
def __init__(self, message="Session has expired."):
|
147
|
+
super().__init__(message)
|
146
148
|
|
147
149
|
|
148
150
|
class SecondFactorRequiredError(SessionError):
|
sanic_security/models.py
CHANGED
@@ -8,12 +8,12 @@ from typing import Union
|
|
8
8
|
import jwt
|
9
9
|
from jwt import DecodeError
|
10
10
|
from sanic.request import Request
|
11
|
-
from sanic.response import HTTPResponse
|
11
|
+
from sanic.response import HTTPResponse
|
12
12
|
from tortoise import fields, Model
|
13
13
|
from tortoise.exceptions import DoesNotExist, ValidationError
|
14
14
|
from tortoise.validators import RegexValidator
|
15
15
|
|
16
|
-
from sanic_security.configuration import config
|
16
|
+
from sanic_security.configuration import config
|
17
17
|
from sanic_security.exceptions import *
|
18
18
|
from sanic_security.utils import (
|
19
19
|
get_ip,
|
@@ -52,7 +52,7 @@ class BaseModel(Model):
|
|
52
52
|
Base Sanic Security model that all other models derive from.
|
53
53
|
|
54
54
|
Attributes:
|
55
|
-
id (
|
55
|
+
id (str): Primary key of model.
|
56
56
|
date_created (datetime): Time this model was created in the database.
|
57
57
|
date_updated (datetime): Time this model was updated in the database.
|
58
58
|
deleted (bool): Renders the model filterable without removing from the database.
|
@@ -67,7 +67,7 @@ class BaseModel(Model):
|
|
67
67
|
|
68
68
|
def validate(self) -> None:
|
69
69
|
"""
|
70
|
-
Raises an error with respect to state.
|
70
|
+
Raises an error with respect to model's state.
|
71
71
|
|
72
72
|
Raises:
|
73
73
|
SecurityError
|
@@ -77,7 +77,7 @@ class BaseModel(Model):
|
|
77
77
|
@property
|
78
78
|
def json(self) -> dict:
|
79
79
|
"""
|
80
|
-
A JSON serializable dict to be used in
|
80
|
+
A JSON serializable dict to be used in a request or response.
|
81
81
|
|
82
82
|
Example:
|
83
83
|
Below is an example of this method returning a dict to be used for JSON serialization.
|
@@ -108,16 +108,16 @@ class Account(BaseModel):
|
|
108
108
|
username (str): Public identifier.
|
109
109
|
email (str): Private identifier and can be used for verification.
|
110
110
|
phone (str): Mobile phone number with country code included and can be used for verification. Can be null or empty.
|
111
|
-
password (str): Password of account for protection
|
111
|
+
password (str): Password of account for user protection, must be hashed via Argon2.
|
112
|
+
oauth_id (str): Identifier associated with an OAuth 2.0 authorization flow.
|
112
113
|
disabled (bool): Renders the account unusable but available.
|
113
114
|
verified (bool): Renders the account unusable until verified via two-step verification or other method.
|
114
115
|
roles (ManyToManyRelation[Role]): Roles associated with this account.
|
115
116
|
"""
|
116
117
|
|
117
118
|
username: str = fields.CharField(
|
118
|
-
unique=
|
119
|
+
unique=config.ALLOW_LOGIN_WITH_USERNAME,
|
119
120
|
max_length=32,
|
120
|
-
validators=[RegexValidator(r"^[A-Za-z0-9_-]*$", re.I)],
|
121
121
|
)
|
122
122
|
email: str = fields.CharField(
|
123
123
|
unique=True,
|
@@ -135,6 +135,7 @@ class Account(BaseModel):
|
|
135
135
|
],
|
136
136
|
)
|
137
137
|
password: str = fields.CharField(max_length=255)
|
138
|
+
oauth_id: str = fields.CharField(unique=True, null=True, max_length=255)
|
138
139
|
disabled: bool = fields.BooleanField(default=False)
|
139
140
|
verified: bool = fields.BooleanField(default=False)
|
140
141
|
roles: fields.ManyToManyRelation["Role"] = fields.ManyToManyField(
|
@@ -238,7 +239,7 @@ class Account(BaseModel):
|
|
238
239
|
try:
|
239
240
|
account = await Account.get_via_email(credential)
|
240
241
|
except NotFoundError as e:
|
241
|
-
if
|
242
|
+
if config.ALLOW_LOGIN_WITH_USERNAME:
|
242
243
|
account = await Account.get_via_username(credential)
|
243
244
|
else:
|
244
245
|
raise e
|
@@ -247,7 +248,7 @@ class Account(BaseModel):
|
|
247
248
|
@staticmethod
|
248
249
|
async def get_via_header(request: Request):
|
249
250
|
"""
|
250
|
-
|
251
|
+
Retrieve an account via the basic authorization header.
|
251
252
|
|
252
253
|
Args:
|
253
254
|
request (Request): Sanic request parameter.
|
@@ -276,7 +277,7 @@ class Account(BaseModel):
|
|
276
277
|
@staticmethod
|
277
278
|
async def get_via_phone(phone: str):
|
278
279
|
"""
|
279
|
-
Retrieve an account
|
280
|
+
Retrieve an account via a phone number.
|
280
281
|
|
281
282
|
Args:
|
282
283
|
phone (str): Phone number associated to account being retrieved.
|
@@ -332,7 +333,7 @@ class Session(BaseModel):
|
|
332
333
|
|
333
334
|
async def deactivate(self):
|
334
335
|
"""
|
335
|
-
Renders session deactivated and unusable.
|
336
|
+
Renders session deactivated and therefor unusable.
|
336
337
|
|
337
338
|
Raises:
|
338
339
|
DeactivatedError
|
@@ -350,9 +351,6 @@ class Session(BaseModel):
|
|
350
351
|
Args:
|
351
352
|
response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
|
352
353
|
"""
|
353
|
-
cookie = (
|
354
|
-
f"{security_config.SESSION_PREFIX}_{self.__class__.__name__.lower()[:7]}"
|
355
|
-
)
|
356
354
|
encoded_session = jwt.encode(
|
357
355
|
{
|
358
356
|
"id": self.id,
|
@@ -361,22 +359,18 @@ class Session(BaseModel):
|
|
361
359
|
"bearer": self.bearer.id if isinstance(self.bearer, Account) else None,
|
362
360
|
"ip": self.ip,
|
363
361
|
},
|
364
|
-
|
365
|
-
|
362
|
+
config.SECRET,
|
363
|
+
config.SESSION_ENCODING_ALGORITHM,
|
366
364
|
)
|
367
365
|
response.cookies.add_cookie(
|
368
|
-
|
366
|
+
f"{config.SESSION_PREFIX}_{self.__class__.__name__[:7].lower()}",
|
369
367
|
str(encoded_session),
|
370
|
-
httponly=
|
371
|
-
samesite=
|
372
|
-
secure=
|
373
|
-
domain=
|
374
|
-
expires=(
|
375
|
-
|
376
|
-
if hasattr(self, "refresh_expiration_date")
|
377
|
-
and self.refresh_expiration_date
|
378
|
-
else self.expiration_date
|
379
|
-
),
|
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,
|
380
374
|
)
|
381
375
|
|
382
376
|
@property
|
@@ -405,7 +399,7 @@ class Session(BaseModel):
|
|
405
399
|
cls,
|
406
400
|
request: Request,
|
407
401
|
account: Account,
|
408
|
-
**kwargs: Union[int, str, bool, float, list, dict],
|
402
|
+
**kwargs: dict[str, Union[int, str, bool, float, list, dict]],
|
409
403
|
):
|
410
404
|
"""
|
411
405
|
Creates session with pre-set values.
|
@@ -413,7 +407,7 @@ class Session(BaseModel):
|
|
413
407
|
Args:
|
414
408
|
request (Request): Sanic request parameter.
|
415
409
|
account (Account): Account being associated to the session.
|
416
|
-
**kwargs (
|
410
|
+
**kwargs (Union[int, str, bool, float, list, dict]): Extra arguments applied during session creation.
|
417
411
|
|
418
412
|
Returns:
|
419
413
|
session
|
@@ -426,7 +420,7 @@ class Session(BaseModel):
|
|
426
420
|
Retrieves sessions associated to an account.
|
427
421
|
|
428
422
|
Args:
|
429
|
-
account (
|
423
|
+
account (Account): Account associated with sessions being retrieved.
|
430
424
|
|
431
425
|
Returns:
|
432
426
|
sessions
|
@@ -442,7 +436,7 @@ class Session(BaseModel):
|
|
442
436
|
@classmethod
|
443
437
|
def decode_raw(cls, request: Request) -> dict:
|
444
438
|
"""
|
445
|
-
Decodes JWT token from client cookie into a python dict.
|
439
|
+
Decodes session JWT token from client cookie into a python dict.
|
446
440
|
|
447
441
|
Args:
|
448
442
|
request (Request): Sanic request parameter.
|
@@ -454,16 +448,16 @@ class Session(BaseModel):
|
|
454
448
|
JWTDecodeError
|
455
449
|
"""
|
456
450
|
cookie = request.cookies.get(
|
457
|
-
f"{
|
451
|
+
f"{config.SESSION_PREFIX}_{cls.__name__[:7].lower()}"
|
458
452
|
)
|
459
453
|
try:
|
460
454
|
if not cookie:
|
461
|
-
raise JWTDecodeError
|
455
|
+
raise JWTDecodeError
|
462
456
|
else:
|
463
457
|
return jwt.decode(
|
464
458
|
cookie,
|
465
|
-
|
466
|
-
|
459
|
+
config.PUBLIC_SECRET or config.SECRET,
|
460
|
+
config.SESSION_ENCODING_ALGORITHM,
|
467
461
|
)
|
468
462
|
except DecodeError as e:
|
469
463
|
raise JWTDecodeError(str(e))
|
@@ -471,7 +465,7 @@ class Session(BaseModel):
|
|
471
465
|
@classmethod
|
472
466
|
async def decode(cls, request: Request):
|
473
467
|
"""
|
474
|
-
Decodes session JWT from client cookie
|
468
|
+
Decodes session JWT token from client cookie into a session model.
|
475
469
|
|
476
470
|
Args:
|
477
471
|
request (Request): Sanic request parameter.
|
@@ -498,11 +492,11 @@ class Session(BaseModel):
|
|
498
492
|
|
499
493
|
class VerificationSession(Session):
|
500
494
|
"""
|
501
|
-
Used for
|
495
|
+
Used for client verification challenges that require some form of code or key.
|
502
496
|
|
503
497
|
Attributes:
|
504
|
-
attempts (int): The amount of
|
505
|
-
code (str):
|
498
|
+
attempts (int): The amount of times a user entered a code not equal to this verification sessions code.
|
499
|
+
code (str): A secret key that would be sent via email, text, etc.
|
506
500
|
"""
|
507
501
|
|
508
502
|
attempts: int = fields.IntField(default=0)
|
@@ -521,7 +515,7 @@ class VerificationSession(Session):
|
|
521
515
|
"""
|
522
516
|
if not code or self.code != code.upper():
|
523
517
|
self.attempts += 1
|
524
|
-
if self.attempts <
|
518
|
+
if self.attempts < config.MAX_CHALLENGE_ATTEMPTS:
|
525
519
|
await self.save(update_fields=["attempts"])
|
526
520
|
raise ChallengeError(
|
527
521
|
"Your code does not match verification session code."
|
@@ -532,7 +526,12 @@ class VerificationSession(Session):
|
|
532
526
|
await self.deactivate()
|
533
527
|
|
534
528
|
@classmethod
|
535
|
-
async def new(
|
529
|
+
async def new(
|
530
|
+
cls,
|
531
|
+
request: Request,
|
532
|
+
account: Account,
|
533
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
534
|
+
):
|
536
535
|
raise NotImplementedError
|
537
536
|
|
538
537
|
class Meta:
|
@@ -540,17 +539,20 @@ class VerificationSession(Session):
|
|
540
539
|
|
541
540
|
|
542
541
|
class TwoStepSession(VerificationSession):
|
543
|
-
"""Validates
|
542
|
+
"""Validates client using a code sent via email or text."""
|
544
543
|
|
545
544
|
@classmethod
|
546
|
-
async def new(
|
545
|
+
async def new(
|
546
|
+
cls,
|
547
|
+
request: Request,
|
548
|
+
account: Account,
|
549
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
550
|
+
):
|
547
551
|
return await cls.create(
|
548
552
|
**kwargs,
|
549
553
|
ip=get_ip(request),
|
550
554
|
bearer=account,
|
551
|
-
expiration_date=get_expiration_date(
|
552
|
-
security_config.TWO_STEP_SESSION_EXPIRATION
|
553
|
-
),
|
555
|
+
expiration_date=get_expiration_date(config.TWO_STEP_SESSION_EXPIRATION),
|
554
556
|
code=get_code(True),
|
555
557
|
)
|
556
558
|
|
@@ -559,41 +561,38 @@ class TwoStepSession(VerificationSession):
|
|
559
561
|
|
560
562
|
|
561
563
|
class CaptchaSession(VerificationSession):
|
562
|
-
"""Validates
|
564
|
+
"""Validates client with a captcha challenge via image or audio."""
|
563
565
|
|
564
566
|
@classmethod
|
565
|
-
async def new(
|
567
|
+
async def new(
|
568
|
+
cls,
|
569
|
+
request: Request,
|
570
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
571
|
+
):
|
566
572
|
return await cls.create(
|
567
573
|
**kwargs,
|
568
574
|
ip=get_ip(request),
|
569
575
|
code=get_code(),
|
570
|
-
expiration_date=get_expiration_date(
|
571
|
-
security_config.CAPTCHA_SESSION_EXPIRATION
|
572
|
-
),
|
576
|
+
expiration_date=get_expiration_date(config.CAPTCHA_SESSION_EXPIRATION),
|
573
577
|
)
|
574
578
|
|
575
|
-
def get_image(self) ->
|
579
|
+
def get_image(self) -> bytes:
|
576
580
|
"""
|
577
|
-
Retrieves captcha image
|
581
|
+
Retrieves captcha image data.
|
578
582
|
|
579
583
|
Returns:
|
580
584
|
captcha_image
|
581
585
|
"""
|
582
|
-
return
|
583
|
-
image_generator.generate(self.code, "jpeg").getvalue(),
|
584
|
-
content_type="image/jpeg",
|
585
|
-
)
|
586
|
+
return image_generator.generate(self.code, "jpeg").getvalue()
|
586
587
|
|
587
|
-
def get_audio(self) ->
|
588
|
+
def get_audio(self) -> bytes:
|
588
589
|
"""
|
589
|
-
Retrieves captcha audio
|
590
|
+
Retrieves captcha audio data.
|
590
591
|
|
591
592
|
Returns:
|
592
593
|
captcha_audio
|
593
594
|
"""
|
594
|
-
return
|
595
|
-
bytes(audio_generator.generate(self.code)), content_type="audio/mpeg"
|
596
|
-
)
|
595
|
+
return bytes(audio_generator.generate(self.code))
|
597
596
|
|
598
597
|
class Meta:
|
599
598
|
table = "captcha_session"
|
@@ -604,9 +603,9 @@ class AuthenticationSession(Session):
|
|
604
603
|
Used to authenticate and identify a client.
|
605
604
|
|
606
605
|
Attributes:
|
607
|
-
refresh_expiration_date (
|
606
|
+
refresh_expiration_date (datetime): Date and time the session can no longer be refreshed.
|
608
607
|
requires_second_factor (bool): Determines if session requires a second factor.
|
609
|
-
is_refresh (bool): Will only be true once when instantiated during refresh of expired session.
|
608
|
+
is_refresh (bool): Will only be true once when instantiated during the refresh of expired session.
|
610
609
|
"""
|
611
610
|
|
612
611
|
refresh_expiration_date: datetime.datetime = fields.DatetimeField(null=True)
|
@@ -652,17 +651,21 @@ class AuthenticationSession(Session):
|
|
652
651
|
|
653
652
|
@classmethod
|
654
653
|
async def new(
|
655
|
-
cls,
|
654
|
+
cls,
|
655
|
+
request: Request,
|
656
|
+
account: Account = None,
|
657
|
+
is_refresh=False,
|
658
|
+
**kwargs: Union[int, str, bool, float, list, dict],
|
656
659
|
):
|
657
660
|
authentication_session = await cls.create(
|
658
661
|
**kwargs,
|
659
662
|
bearer=account,
|
660
663
|
ip=get_ip(request),
|
661
664
|
expiration_date=get_expiration_date(
|
662
|
-
|
665
|
+
config.AUTHENTICATION_SESSION_EXPIRATION
|
663
666
|
),
|
664
667
|
refresh_expiration_date=get_expiration_date(
|
665
|
-
|
668
|
+
config.AUTHENTICATION_REFRESH_EXPIRATION
|
666
669
|
),
|
667
670
|
)
|
668
671
|
authentication_session.is_refresh = is_refresh
|
@@ -679,12 +682,12 @@ class Role(BaseModel):
|
|
679
682
|
Attributes:
|
680
683
|
name (str): Name of the role.
|
681
684
|
description (str): Description of the role.
|
682
|
-
permissions (str): Permissions of the role. Must
|
685
|
+
permissions (list[str]): Permissions of the role. Must in wildcard format (printer:query, dashboard:info,delete).
|
683
686
|
"""
|
684
687
|
|
685
688
|
name: str = fields.CharField(unique=True, max_length=255)
|
686
689
|
description: str = fields.CharField(max_length=255, null=True)
|
687
|
-
permissions: str = fields.
|
690
|
+
permissions: list[str] = fields.JSONField(null=True)
|
688
691
|
|
689
692
|
def validate(self) -> None:
|
690
693
|
raise NotImplementedError
|