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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,34 @@
1
1
  import functools
2
- import logging
3
- from fnmatch import fnmatch
4
2
 
3
+ from sanic.log import logger
5
4
  from sanic.request import Request
6
5
  from tortoise.exceptions import DoesNotExist
7
6
 
8
7
  from sanic_security.authentication import authenticate
9
- from sanic_security.exceptions import AuthorizationError
8
+ from sanic_security.exceptions import AuthorizationError, AnonymousError
10
9
  from sanic_security.models import Role, Account, AuthenticationSession
11
10
  from sanic_security.utils import get_ip
12
11
 
13
12
  """
14
- An effective, simple, and async security library for the Sanic framework.
15
- Copyright (C) 2020-present Aidan Stewart
16
-
17
- This program is free software: you can redistribute it and/or modify
18
- it under the terms of the GNU Affero General Public License as published
19
- by the Free Software Foundation, either version 3 of the License, or
20
- (at your option) any later version.
21
-
22
- This program is distributed in the hope that it will be useful,
23
- but WITHOUT ANY WARRANTY; without even the implied warranty of
24
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
- GNU Affero General Public License for more details.
26
-
27
- You should have received a copy of the GNU Affero General Public License
28
- along with this program. If not, see <https://www.gnu.org/licenses/>.
13
+ Copyright (c) 2020-present Nicholas Aidan Stewart
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
29
32
  """
30
33
 
31
34
 
@@ -51,19 +54,48 @@ async def check_permissions(
51
54
  UnverifiedError
52
55
  DisabledError
53
56
  AuthorizationError
57
+ AnonymousError
54
58
  """
55
59
  authentication_session = await authenticate(request)
60
+ if authentication_session.anonymous:
61
+ logger.warning(
62
+ f"Client {get_ip(request)} attempted an unauthorized action anonymously."
63
+ )
64
+ raise AnonymousError
56
65
  roles = await authentication_session.bearer.roles.filter(deleted=False).all()
57
66
  for role in roles:
58
- for required_permission, role_permission in zip(
59
- required_permissions, role.permissions.split(", ")
60
- ):
61
- if fnmatch(required_permission, role_permission):
62
- return authentication_session
63
- logging.warning(f"Client ({get_ip(request)}) has insufficient permissions.")
67
+ for role_permission in role.permissions:
68
+ for required_permission in required_permissions:
69
+ if check_wildcard(role_permission, required_permission):
70
+ return authentication_session
71
+ logger.warning(
72
+ f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action."
73
+ )
64
74
  raise AuthorizationError("Insufficient permissions required for this action.")
65
75
 
66
76
 
77
+ def check_wildcard(wildcard: str, pattern: str):
78
+ """
79
+ Evaluates if the wildcard matches the pattern.
80
+
81
+ Args:
82
+ wildcard (str): A wildcard string (e.g., "a:b:c").
83
+ pattern (str): A wildcard pattern optional (`*`) or comma-separated values to match against (e.g., "a:b,c:*").
84
+
85
+ Returns:
86
+ is_match
87
+ """
88
+ wildcard_parts = [set(part.split(",")) for part in wildcard.split(":")]
89
+ pattern_parts = [set(part.split(",")) for part in pattern.split(":")]
90
+ for i, pattern_part in enumerate(pattern_parts):
91
+ if i >= len(wildcard_parts):
92
+ return False
93
+ wildcard_part = wildcard_parts[i]
94
+ if "*" not in wildcard_part and not wildcard_part.issuperset(pattern_part):
95
+ return False
96
+ return all("*" in part for part in wildcard_parts[len(pattern_parts) :])
97
+
98
+
67
99
  async def check_roles(request: Request, *required_roles: str) -> AuthenticationSession:
68
100
  """
69
101
  Authenticates client and determines if the account has sufficient roles for an action.
@@ -84,17 +116,55 @@ async def check_roles(request: Request, *required_roles: str) -> AuthenticationS
84
116
  UnverifiedError
85
117
  DisabledError
86
118
  AuthorizationError
119
+ AnonymousError
87
120
  """
88
121
  authentication_session = await authenticate(request)
89
- roles = await authentication_session.bearer.roles.filter(deleted=False).all()
90
- for role in roles:
91
- if role.name in required_roles:
92
- return authentication_session
93
- logging.warning(f"Client ({get_ip(request)}) has insufficient roles.")
94
- raise AuthorizationError("Insufficient roles required for this action.")
122
+ if authentication_session.anonymous:
123
+ logger.warning(
124
+ f"Client {get_ip(request)} attempted an unauthorized action anonymously."
125
+ )
126
+ raise AnonymousError
127
+ if set(required_roles) & {
128
+ role.name
129
+ for role in await authentication_session.bearer.roles.filter(
130
+ deleted=False
131
+ ).all()
132
+ }:
133
+ return authentication_session
134
+ logger.warning(
135
+ f"Client {get_ip(request)} with account {authentication_session.bearer.id} attempted an unauthorized action"
136
+ )
137
+ raise AuthorizationError("Insufficient roles required for this action")
95
138
 
96
139
 
97
- def require_permissions(*required_permissions: str):
140
+ async def assign_role(
141
+ name: str,
142
+ account: Account,
143
+ description: str,
144
+ *permissions: str,
145
+ ) -> Role:
146
+ """
147
+ Easy account role assignment, role being assigned to an account will be created if it doesn't exist.
148
+
149
+ Args:
150
+ name (str): The name of the role associated with the account.
151
+ account (Account): The account associated with the created role.
152
+ description (str): The description of the role associated with the account.
153
+ *permissions (Tuple[str, ...]): The permissions of the role associated with the account, must be in wildcard format.
154
+ """
155
+ try:
156
+ role = await Role.filter(name=name).get()
157
+ except DoesNotExist:
158
+ role = await Role.create(
159
+ name=name,
160
+ description=description,
161
+ permissions=permissions,
162
+ )
163
+ await account.roles.add(role)
164
+ return role
165
+
166
+
167
+ def requires_permission(*required_permissions: str):
98
168
  """
99
169
  Authenticates client and determines if the account has sufficient permissions for an action.
100
170
 
@@ -105,8 +175,8 @@ def require_permissions(*required_permissions: str):
105
175
  This method is not called directly and instead used as a decorator:
106
176
 
107
177
  @app.post("api/auth/perms")
108
- @require_permissions("admin:update", "employee:add")
109
- async def on_require_perms(request):
178
+ @requires_permission("admin:update", "employee:add")
179
+ async def on_authorize(request):
110
180
  return text("Account permitted.")
111
181
 
112
182
  Raises:
@@ -118,14 +188,13 @@ def require_permissions(*required_permissions: str):
118
188
  UnverifiedError
119
189
  DisabledError
120
190
  AuthorizationError
191
+ AnonymousError
121
192
  """
122
193
 
123
194
  def decorator(func):
124
195
  @functools.wraps(func)
125
196
  async def wrapper(request, *args, **kwargs):
126
- request.ctx.authentication_session = await check_permissions(
127
- request, *required_permissions
128
- )
197
+ await check_permissions(request, *required_permissions)
129
198
  return await func(request, *args, **kwargs)
130
199
 
131
200
  return wrapper
@@ -133,7 +202,7 @@ def require_permissions(*required_permissions: str):
133
202
  return decorator
134
203
 
135
204
 
136
- def require_roles(*required_roles: str):
205
+ def requires_role(*required_roles: str):
137
206
  """
138
207
  Authenticates client and determines if the account has sufficient roles for an action.
139
208
 
@@ -144,8 +213,8 @@ def require_roles(*required_roles: str):
144
213
  This method is not called directly and instead used as a decorator:
145
214
 
146
215
  @app.post("api/auth/roles")
147
- @require_roles("Admin", "Moderator")
148
- async def on_require_roles(request):
216
+ @requires_role("Admin", "Moderator")
217
+ async def on_authorize(request):
149
218
  return text("Account permitted")
150
219
 
151
220
  Raises:
@@ -157,38 +226,15 @@ def require_roles(*required_roles: str):
157
226
  UnverifiedError
158
227
  DisabledError
159
228
  AuthorizationError
229
+ AnonymousError
160
230
  """
161
231
 
162
232
  def decorator(func):
163
233
  @functools.wraps(func)
164
234
  async def wrapper(request, *args, **kwargs):
165
- request.ctx.authentication_session = await check_roles(
166
- request, *required_roles
167
- )
235
+ await check_roles(request, *required_roles)
168
236
  return await func(request, *args, **kwargs)
169
237
 
170
238
  return wrapper
171
239
 
172
240
  return decorator
173
-
174
-
175
- async def assign_role(
176
- name: str, account: Account, permissions: str = None, description: str = None
177
- ) -> Role:
178
- """
179
- Easy account role assignment. Role being assigned to an account will be created if it doesn't exist.
180
-
181
- Args:
182
- name (str): The name of the role associated with the account.
183
- account (Account): The account associated with the created role.
184
- permissions (str): The permissions of the role associated with the account. Permissions must be separated via comma and in wildcard format.
185
- description (str): The description of the role associated with the account.
186
- """
187
- try:
188
- role = await Role.filter(name=name).get()
189
- except DoesNotExist:
190
- role = await Role.create(
191
- description=description, permissions=permissions, name=name
192
- )
193
- await account.roles.add(role)
194
- return role
@@ -2,40 +2,47 @@ from os import environ
2
2
 
3
3
  from sanic.utils import str_to_bool
4
4
 
5
-
6
5
  """
7
- An effective, simple, and async security library for the Sanic framework.
8
- Copyright (C) 2020-present Aidan Stewart
9
-
10
- This program is free software: you can redistribute it and/or modify
11
- it under the terms of the GNU Affero General Public License as published
12
- by the Free Software Foundation, either version 3 of the License, or
13
- (at your option) any later version.
14
-
15
- This program is distributed in the hope that it will be useful,
16
- but WITHOUT ANY WARRANTY; without even the implied warranty of
17
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
- GNU Affero General Public License for more details.
19
-
20
- You should have received a copy of the GNU Affero General Public License
21
- along with this program. If not, see <https://www.gnu.org/licenses/>.
6
+ Copyright (c) 2020-present Nicholas Aidan Stewart
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
22
25
  """
23
26
 
24
-
25
27
  DEFAULT_CONFIG = {
26
28
  "SECRET": "This is a big secret. Shhhhh",
27
29
  "PUBLIC_SECRET": None,
28
- "SESSION_SAMESITE": "strict",
30
+ "OAUTH_CLIENT": None,
31
+ "OAUTH_SECRET": None,
32
+ "OAUTH_REDIRECT": None,
33
+ "SESSION_SAMESITE": "Strict",
29
34
  "SESSION_SECURE": True,
30
35
  "SESSION_HTTPONLY": True,
31
36
  "SESSION_DOMAIN": None,
32
- "SESSION_PREFIX": "token",
37
+ "SESSION_PREFIX": "tkn",
33
38
  "SESSION_ENCODING_ALGORITHM": "HS256",
34
- "MAX_CHALLENGE_ATTEMPTS": 5,
35
- "CAPTCHA_SESSION_EXPIRATION": 60,
39
+ "MAX_CHALLENGE_ATTEMPTS": 3,
40
+ "CAPTCHA_SESSION_EXPIRATION": 180,
36
41
  "CAPTCHA_FONT": "captcha-font.ttf",
37
- "TWO_STEP_SESSION_EXPIRATION": 200,
38
- "AUTHENTICATION_SESSION_EXPIRATION": 2592000,
42
+ "CAPTCHA_VOICE": "captcha-voice/",
43
+ "TWO_STEP_SESSION_EXPIRATION": 300,
44
+ "AUTHENTICATION_SESSION_EXPIRATION": 86400,
45
+ "AUTHENTICATION_REFRESH_EXPIRATION": 604800,
39
46
  "ALLOW_LOGIN_WITH_USERNAME": False,
40
47
  "INITIAL_ADMIN_EMAIL": "admin@example.com",
41
48
  "INITIAL_ADMIN_PASSWORD": "admin123",
@@ -50,6 +57,9 @@ class Config(dict):
50
57
  Attributes:
51
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.
52
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.
53
63
  SESSION_SAMESITE (str): The SameSite attribute of session cookies.
54
64
  SESSION_SECURE (bool): The Secure attribute of session cookies.
55
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.
@@ -59,8 +69,10 @@ class Config(dict):
59
69
  MAX_CHALLENGE_ATTEMPTS (str): The maximum amount of session challenge attempts allowed.
60
70
  CAPTCHA_SESSION_EXPIRATION (int): The amount of seconds till captcha session expiration on creation. Setting to 0 will disable expiration.
61
71
  CAPTCHA_FONT (str): The file path to the font being used for captcha generation.
62
- TWO_STEP_SESSION_EXPIRATION (int): The amount of seconds till two step session expiration on creation. Setting to 0 will disable expiration.
63
- AUTHENTICATION_SESSION_EXPIRATION (bool): The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration.
72
+ CAPTCHA_VOICE (str): The directory of the voice library being used for audio captcha generation.
73
+ TWO_STEP_SESSION_EXPIRATION (int): The amount of seconds till two-step session expiration on creation. Setting to 0 will disable expiration.
74
+ AUTHENTICATION_SESSION_EXPIRATION (int): The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration.
75
+ AUTHENTICATION_REFRESH_EXPIRATION (int): The amount of seconds till authentication session refresh expiration. Setting to 0 will disable refresh mechanism.
64
76
  ALLOW_LOGIN_WITH_USERNAME (bool): Allows login via username and email.
65
77
  INITIAL_ADMIN_EMAIL (str): Email used when creating the initial admin account.
66
78
  INITIAL_ADMIN_PASSWORD (str): Password used when creating the initial admin account.
@@ -69,6 +81,9 @@ class Config(dict):
69
81
 
70
82
  SECRET: str
71
83
  PUBLIC_SECRET: str
84
+ OAUTH_CLIENT: str
85
+ OAUTH_SECRET: str
86
+ OAUTH_REDIRECT: str
72
87
  SESSION_SAMESITE: str
73
88
  SESSION_SECURE: bool
74
89
  SESSION_HTTPONLY: bool
@@ -78,8 +93,10 @@ class Config(dict):
78
93
  MAX_CHALLENGE_ATTEMPTS: int
79
94
  CAPTCHA_SESSION_EXPIRATION: int
80
95
  CAPTCHA_FONT: str
96
+ CAPTCHA_VOICE: str
81
97
  TWO_STEP_SESSION_EXPIRATION: int
82
98
  AUTHENTICATION_SESSION_EXPIRATION: int
99
+ AUTHENTICATION_REFRESH_EXPIRATION: int
83
100
  ALLOW_LOGIN_WITH_USERNAME: bool
84
101
  INITIAL_ADMIN_EMAIL: str
85
102
  INITIAL_ADMIN_PASSWORD: str
@@ -3,21 +3,25 @@ from sanic.exceptions import SanicException
3
3
  from sanic_security.utils import json
4
4
 
5
5
  """
6
- An effective, simple, and async security library for the Sanic framework.
7
- Copyright (C) 2020-present Aidan Stewart
8
-
9
- This program is free software: you can redistribute it and/or modify
10
- it under the terms of the GNU Affero General Public License as published
11
- by the Free Software Foundation, either version 3 of the License, or
12
- (at your option) any later version.
13
-
14
- This program is distributed in the hope that it will be useful,
15
- but WITHOUT ANY WARRANTY; without even the implied warranty of
16
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
- GNU Affero General Public License for more details.
18
-
19
- You should have received a copy of the GNU Affero General Public License
20
- along with this program. If not, see <https://www.gnu.org/licenses/>.
6
+ Copyright (c) 2020-present Nicholas Aidan Stewart
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
21
25
  """
22
26
 
23
27
 
@@ -53,7 +57,25 @@ class DeletedError(SecurityError):
53
57
  """
54
58
 
55
59
  def __init__(self, message):
56
- super().__init__(message, 410)
60
+ super().__init__(message, 404)
61
+
62
+
63
+ class CredentialsError(SecurityError):
64
+ """
65
+ Raised when credentials are invalid.
66
+ """
67
+
68
+ def __init__(self, message, code=400):
69
+ super().__init__(message, code)
70
+
71
+
72
+ class OAuthError(SecurityError):
73
+ """
74
+ Raised when an error occurs during OAuth flow.
75
+ """
76
+
77
+ def __init__(self, message, code=401):
78
+ super().__init__(message, code)
57
79
 
58
80
 
59
81
  class AccountError(SecurityError):
@@ -106,7 +128,9 @@ class JWTDecodeError(SessionError):
106
128
  Raised when client JWT is invalid.
107
129
  """
108
130
 
109
- def __init__(self, message, code=400):
131
+ def __init__(
132
+ self, message="Session token invalid, not provided, or expired.", code=401
133
+ ):
110
134
  super().__init__(message, code)
111
135
 
112
136
 
@@ -115,7 +139,11 @@ class DeactivatedError(SessionError):
115
139
  Raised when session is deactivated.
116
140
  """
117
141
 
118
- def __init__(self, message: str = "Session is deactivated.", code: int = 401):
142
+ def __init__(
143
+ self,
144
+ message: str = "Session has been deactivated.",
145
+ code: int = 401,
146
+ ):
119
147
  super().__init__(message, code)
120
148
 
121
149
 
@@ -124,8 +152,8 @@ class ExpiredError(SessionError):
124
152
  Raised when session has expired.
125
153
  """
126
154
 
127
- def __init__(self):
128
- super().__init__("Session has expired")
155
+ def __init__(self, message="Session has expired."):
156
+ super().__init__(message)
129
157
 
130
158
 
131
159
  class SecondFactorRequiredError(SessionError):
@@ -173,10 +201,16 @@ class AuthorizationError(SecurityError):
173
201
  super().__init__(message, 403)
174
202
 
175
203
 
176
- class CredentialsError(SecurityError):
204
+ class AnonymousError(AuthorizationError):
177
205
  """
178
- Raised when credentials are invalid.
206
+ Raised when attempting to authorize an anonymous user.
179
207
  """
180
208
 
181
- def __init__(self, message, code=400):
182
- super().__init__(message, code)
209
+ def __init__(self):
210
+ super().__init__("Session is anonymous.")
211
+
212
+
213
+ class AuditWarning(Warning):
214
+ """
215
+ Raised when configuration may be dangerous.
216
+ """