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.
@@ -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")
138
+
139
+
140
+ async def assign_role(
141
+ name: str,
142
+ account: Account,
143
+ description: str = None,
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
95
165
 
96
166
 
97
- def require_permissions(*required_permissions: str):
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,21 +188,21 @@ 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
201
+
132
202
  return decorator
133
203
 
134
204
 
135
- def require_roles(*required_roles: str):
205
+ def requires_role(*required_roles: str):
136
206
  """
137
207
  Authenticates client and determines if the account has sufficient roles for an action.
138
208
 
@@ -143,8 +213,8 @@ def require_roles(*required_roles: str):
143
213
  This method is not called directly and instead used as a decorator:
144
214
 
145
215
  @app.post("api/auth/roles")
146
- @require_roles("Admin", "Moderator")
147
- async def on_require_roles(request):
216
+ @requires_role("Admin", "Moderator")
217
+ async def on_authorize(request):
148
218
  return text("Account permitted")
149
219
 
150
220
  Raises:
@@ -156,38 +226,15 @@ def require_roles(*required_roles: str):
156
226
  UnverifiedError
157
227
  DisabledError
158
228
  AuthorizationError
229
+ AnonymousError
159
230
  """
160
231
 
161
232
  def decorator(func):
162
233
  @functools.wraps(func)
163
234
  async def wrapper(request, *args, **kwargs):
164
- request.ctx.authentication_session = await check_roles(
165
- request, *required_roles
166
- )
235
+ await check_roles(request, *required_roles)
167
236
  return await func(request, *args, **kwargs)
168
237
 
169
238
  return wrapper
170
239
 
171
240
  return decorator
172
-
173
-
174
- async def assign_role(
175
- name: str, account: Account, permissions: str = None, description: str = None
176
- ) -> Role:
177
- """
178
- Easy account role assignment. Role being assigned to an account will be created if it doesn't exist.
179
-
180
- Args:
181
- name (str): The name of the role associated with the account.
182
- account (Account): The account associated with the created role.
183
- permissions (str): The permissions of the role associated with the account. Permissions must be separated via comma and in wildcard format.
184
- description (str): The description of the role associated with the account.
185
- """
186
- try:
187
- role = await Role.filter(name=name).get()
188
- except DoesNotExist:
189
- role = await Role.create(
190
- description=description, permissions=permissions, name=name
191
- )
192
- await account.roles.add(role)
193
- 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
+ """