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,124 +1,46 @@
1
- import base64
2
1
  import functools
3
2
  import re
3
+ import warnings
4
4
 
5
- from argon2 import PasswordHasher
6
- from argon2.exceptions import VerifyMismatchError
5
+ from argon2.exceptions import VerificationError, InvalidHashError
7
6
  from sanic import Sanic
8
7
  from sanic.log import logger
9
8
  from sanic.request import Request
10
- from tortoise.exceptions import DoesNotExist
9
+ from tortoise.exceptions import DoesNotExist, ValidationError, IntegrityError
11
10
 
12
- from sanic_security.configuration import config as security_config
11
+ from sanic_security.configuration import config, DEFAULT_CONFIG
13
12
  from sanic_security.exceptions import (
14
- NotFoundError,
15
13
  CredentialsError,
16
14
  DeactivatedError,
17
15
  SecondFactorFulfilledError,
16
+ ExpiredError,
17
+ AuditWarning,
18
18
  )
19
19
  from sanic_security.models import Account, AuthenticationSession, Role, TwoStepSession
20
- from sanic_security.utils import get_ip
20
+ from sanic_security.utils import get_ip, password_hasher
21
21
 
22
22
  """
23
- An effective, simple, and async security library for the Sanic framework.
24
- Copyright (C) 2020-present Aidan Stewart
25
-
26
- This program is free software: you can redistribute it and/or modify
27
- it under the terms of the GNU Affero General Public License as published
28
- by the Free Software Foundation, either version 3 of the License, or
29
- (at your option) any later version.
30
-
31
- This program is distributed in the hope that it will be useful,
32
- but WITHOUT ANY WARRANTY; without even the implied warranty of
33
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
34
- GNU Affero General Public License for more details.
35
-
36
- You should have received a copy of the GNU Affero General Public License
37
- along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+ Copyright (c) 2020-present Nicholas Aidan Stewart
24
+
25
+ Permission is hereby granted, free of charge, to any person obtaining a copy
26
+ of this software and associated documentation files (the "Software"), to deal
27
+ in the Software without restriction, including without limitation the rights
28
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
29
+ copies of the Software, and to permit persons to whom the Software is
30
+ furnished to do so, subject to the following conditions:
31
+
32
+ The above copyright notice and this permission notice shall be included in all
33
+ copies or substantial portions of the Software.
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
36
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
37
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
38
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
39
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
40
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
41
+ SOFTWARE.
38
42
  """
39
43
 
40
- password_hasher = PasswordHasher()
41
-
42
-
43
- def validate_email(email: str) -> str:
44
- """
45
- Validates email format.
46
-
47
- Args:
48
- email (str): Email being validated.
49
-
50
- Returns:
51
- email
52
-
53
- Raises:
54
- CredentialsError
55
- """
56
- if not re.search(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email):
57
- raise CredentialsError("Please use a valid email address.", 400)
58
- return email
59
-
60
-
61
- def validate_username(username: str) -> str:
62
- """
63
- Validates username format.
64
-
65
- Args:
66
- username (str): Username being validated.
67
-
68
- Returns:
69
- username
70
-
71
- Raises:
72
- CredentialsError
73
- """
74
- if not re.search(r"^[A-Za-z0-9_-]{3,32}$", username):
75
- raise CredentialsError(
76
- "Username must be between 3-32 characters and not contain any special characters other than _ or -.",
77
- 400,
78
- )
79
- return username
80
-
81
-
82
- def validate_phone(phone: str) -> str:
83
- """
84
- Validates phone number format.
85
-
86
- Args:
87
- phone (str): Phone number being validated.
88
-
89
- Returns:
90
- phone
91
-
92
- Raises:
93
- CredentialsError
94
- """
95
- if phone and not re.search(
96
- r"^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", phone
97
- ):
98
- raise CredentialsError("Please use a valid phone number.", 400)
99
- return phone
100
-
101
-
102
- def validate_password(password: str) -> str:
103
- """
104
- Validates password requirements.
105
-
106
- Args:
107
- password (str): Password being validated.
108
-
109
- Returns:
110
- password
111
-
112
- Raises:
113
- CredentialsError
114
- """
115
- if not re.search(r"^(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&+=!]).*$", password):
116
- raise CredentialsError(
117
- "Password must contain one capital letter, one number, and one special character",
118
- 400,
119
- )
120
- return password
121
-
122
44
 
123
45
  async def register(
124
46
  request: Request, verified: bool = False, disabled: bool = False
@@ -137,42 +59,47 @@ async def register(
137
59
  Raises:
138
60
  CredentialsError
139
61
  """
140
- email_lower = validate_email(request.form.get("email").lower())
141
- if await Account.filter(email=email_lower).exists():
142
- raise CredentialsError("An account with this email already exists.", 409)
143
- elif await Account.filter(
144
- username=validate_username(request.form.get("username"))
145
- ).exists():
146
- raise CredentialsError("An account with this username already exists.", 409)
147
- elif (
148
- request.form.get("phone")
149
- and await Account.filter(
150
- phone=validate_phone(request.form.get("phone"))
151
- ).exists()
152
- ):
153
- raise CredentialsError("An account with this phone number already exists.", 409)
154
- validate_password(request.form.get("password"))
155
- account = await Account.create(
156
- email=email_lower,
157
- username=request.form.get("username"),
158
- password=password_hasher.hash(request.form.get("password")),
159
- phone=request.form.get("phone"),
160
- verified=verified,
161
- disabled=disabled,
162
- )
163
- return account
62
+ try:
63
+ account = await Account.create(
64
+ email=request.form.get("email").lower(),
65
+ username=request.form.get("username"),
66
+ password=password_hasher.hash(
67
+ validate_password(request.form.get("password"))
68
+ ),
69
+ phone=request.form.get("phone"),
70
+ verified=verified,
71
+ disabled=disabled,
72
+ )
73
+ logger.info(f"Client {get_ip(request)} has registered account {account.id}.")
74
+ return account
75
+ except ValidationError as e:
76
+ raise CredentialsError(
77
+ "Username must be 3-32 characters long."
78
+ if "username" in e.args[0]
79
+ else "Invalid email or phone number."
80
+ )
81
+ except IntegrityError as e:
82
+ raise CredentialsError(
83
+ f"An account with this {"username" if "username" in str(e.args[0]) else "email or phone number"} "
84
+ "may already exist.",
85
+ 409,
86
+ )
164
87
 
165
88
 
166
89
  async def login(
167
- request: Request, account: Account = None, require_second_factor: bool = False
90
+ request: Request,
91
+ account: Account = None,
92
+ require_second_factor: bool = False,
93
+ password: str = None,
168
94
  ) -> AuthenticationSession:
169
95
  """
170
96
  Login with email or username (if enabled) and password.
171
97
 
172
98
  Args:
173
- request (Request): Sanic request parameter. Login credentials are retrieved via the authorization header.
174
- account (Account): Account being logged into, overrides retrieving account via email or username in form-data.
99
+ request (Request): Sanic request parameter, login credentials are retrieved via the authorization header.
100
+ account (Account): Account being logged into, overrides account retrieved via email or username.
175
101
  require_second_factor (bool): Determines authentication session second factor requirement on login.
102
+ password (str): Overrides user's password attempt retrieved via the authorization header.
176
103
 
177
104
  Returns:
178
105
  authentication_session
@@ -184,36 +111,26 @@ async def login(
184
111
  UnverifiedError
185
112
  DisabledError
186
113
  """
187
- if request.headers.get("Authorization"):
188
- authorization_type, credentials = request.headers.get("Authorization").split()
189
- if authorization_type == "Basic":
190
- email_or_username, password = (
191
- base64.b64decode(credentials).decode().split(":")
192
- )
193
- else:
194
- raise CredentialsError("Invalid authorization type.")
195
- else:
196
- raise CredentialsError("Credentials not provided.")
197
114
  if not account:
198
- try:
199
- account = await Account.get_via_email(email_or_username.lower())
200
- except NotFoundError as e:
201
- if security_config.ALLOW_LOGIN_WITH_USERNAME:
202
- account = await Account.get_via_username(email_or_username)
203
- else:
204
- raise e
115
+ account, password = await Account.get_via_header(request)
116
+ elif not password:
117
+ raise CredentialsError("Password parameter is empty.")
205
118
  try:
206
119
  password_hasher.verify(account.password, password)
207
120
  if password_hasher.check_needs_rehash(account.password):
208
121
  account.password = password_hasher.hash(password)
209
122
  await account.save(update_fields=["password"])
210
123
  account.validate()
211
- return await AuthenticationSession.new(
124
+ authentication_session = await AuthenticationSession.new(
212
125
  request, account, requires_second_factor=require_second_factor
213
126
  )
214
- except VerifyMismatchError:
127
+ logger.info(
128
+ f"Client {get_ip(request)} has logged in with authentication session {authentication_session.id}."
129
+ )
130
+ return authentication_session
131
+ except (VerificationError, InvalidHashError):
215
132
  logger.warning(
216
- f"Client ({get_ip(request)}) login password attempt is incorrect"
133
+ f"Client {get_ip(request)} has failed to log into account {account.id}."
217
134
  )
218
135
  raise CredentialsError("Incorrect password.", 401)
219
136
 
@@ -238,18 +155,18 @@ async def logout(request: Request) -> AuthenticationSession:
238
155
  raise DeactivatedError("Already logged out.", 403)
239
156
  authentication_session.active = False
240
157
  await authentication_session.save(update_fields=["active"])
158
+ logger.info(
159
+ f"Client {get_ip(request)} has logged out with authentication session {authentication_session.id}."
160
+ )
241
161
  return authentication_session
242
162
 
243
163
 
244
- async def authenticate(request: Request) -> AuthenticationSession:
164
+ async def fulfill_second_factor(request: Request) -> AuthenticationSession:
245
165
  """
246
- Validates client's authentication session and account.
166
+ Fulfills client authentication session's second factor requirement via two-step session code.
247
167
 
248
168
  Args:
249
- request (Request): Sanic request parameter.
250
-
251
- Returns:
252
- authentication_session
169
+ request (Request): Sanic request parameter. Request body should contain form-data with the following argument(s): code.
253
170
 
254
171
  Raises:
255
172
  NotFoundError
@@ -257,50 +174,64 @@ async def authenticate(request: Request) -> AuthenticationSession:
257
174
  DeletedError
258
175
  ExpiredError
259
176
  DeactivatedError
260
- UnverifiedError
261
- DisabledError
262
- SecondFactorRequiredError
177
+ ChallengeError
178
+ MaxedOutChallengeError
179
+ SecondFactorFulfilledError
180
+
181
+ Returns:
182
+ authentication_session
263
183
  """
264
184
  authentication_session = await AuthenticationSession.decode(request)
265
- authentication_session.validate()
266
- authentication_session.bearer.validate()
185
+ if not authentication_session.requires_second_factor:
186
+ raise SecondFactorFulfilledError
187
+ two_step_session = await TwoStepSession.decode(request)
188
+ two_step_session.validate()
189
+ await two_step_session.check_code(request.form.get("code"))
190
+ authentication_session.requires_second_factor = False
191
+ await authentication_session.save(update_fields=["requires_second_factor"])
192
+ logger.info(
193
+ f"Client {get_ip(request)} has fulfilled authentication session {authentication_session.id} second factor."
194
+ )
267
195
  return authentication_session
268
196
 
269
197
 
270
- async def fulfill_second_factor(request: Request) -> AuthenticationSession:
198
+ async def authenticate(request: Request) -> AuthenticationSession:
271
199
  """
272
- Fulfills client authentication session's second factor requirement via two-step session code.
200
+ Validates client's authentication session and account. New/Refreshed session automatically returned if client's
201
+ session expired during authentication.
273
202
 
274
203
  Args:
275
- request (Request): Sanic request parameter. Request body should contain form-data with the following argument(s): code.
204
+ request (Request): Sanic request parameter.
205
+
206
+ Returns:
207
+ authentication_session
276
208
 
277
209
  Raises:
278
210
  NotFoundError
279
211
  JWTDecodeError
280
212
  DeletedError
281
- ExpiredError
282
213
  DeactivatedError
283
- ChallengeError
284
- MaxedOutChallengeError
285
- SecondFactorFulfilledError
286
-
287
- Returns:
288
- authentication_Session
214
+ UnverifiedError
215
+ DisabledError
216
+ SecondFactorRequiredError
217
+ ExpiredError
289
218
  """
290
219
  authentication_session = await AuthenticationSession.decode(request)
291
- two_step_session = await TwoStepSession.decode(request)
292
- if not authentication_session.requires_second_factor:
293
- raise SecondFactorFulfilledError()
294
- two_step_session.validate()
295
- await two_step_session.check_code(request, request.form.get("code"))
296
- authentication_session.requires_second_factor = False
297
- await authentication_session.save(update_fields=["requires_second_factor"])
220
+ try:
221
+ authentication_session.validate()
222
+ if not authentication_session.anonymous:
223
+ authentication_session.bearer.validate()
224
+ except ExpiredError:
225
+ authentication_session = await authentication_session.refresh(request)
226
+ authentication_session.is_refresh = True
227
+ request.ctx.session = authentication_session
298
228
  return authentication_session
299
229
 
300
230
 
301
231
  def requires_authentication(arg=None):
302
232
  """
303
- Validates client's authentication session and account.
233
+ Validates client's authentication session and account. New/Refreshed session automatically returned if client's
234
+ session expired during authentication.
304
235
 
305
236
  Example:
306
237
  This method is not called directly and instead used as a decorator:
@@ -314,60 +245,118 @@ def requires_authentication(arg=None):
314
245
  NotFoundError
315
246
  JWTDecodeError
316
247
  DeletedError
317
- ExpiredError
318
248
  DeactivatedError
319
249
  UnverifiedError
320
250
  DisabledError
251
+ SecondFactorRequiredError
252
+ ExpiredError
321
253
  """
322
254
 
323
255
  def decorator(func):
324
256
  @functools.wraps(func)
325
257
  async def wrapper(request, *args, **kwargs):
326
- request.ctx.authentication_session = await authenticate(request)
258
+ await authenticate(request)
327
259
  return await func(request, *args, **kwargs)
328
260
 
329
261
  return wrapper
330
262
 
331
- if callable(arg):
332
- return decorator(arg)
333
- else:
334
- return decorator
263
+ return decorator(arg) if callable(arg) else decorator
264
+
265
+
266
+ def validate_password(password: str) -> str:
267
+ """
268
+ Validates password requirements.
269
+
270
+ Args:
271
+ password (str): Password being validated.
272
+
273
+ Returns:
274
+ password
275
+
276
+ Raises:
277
+ CredentialsError
278
+ """
279
+ if not re.search(r"^(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&+=!]).*$", password):
280
+ raise CredentialsError(
281
+ "Password must contain one capital letter, one number, and one special character",
282
+ 400,
283
+ )
284
+ return password
335
285
 
336
286
 
337
- def create_initial_admin_account(app: Sanic) -> None:
287
+ def initialize_security(app: Sanic, create_root: bool = True) -> None:
338
288
  """
339
- Creates the initial admin account that can be logged into and has complete authoritative access.
289
+ Audits configuration, creates root administrator account, and attaches session middleware.
340
290
 
341
291
  Args:
342
- app (Sanic): The main Sanic application instance.
292
+ app (Sanic): Sanic application instance.
293
+ create_root (bool): Determines root account creation on initialization.
343
294
  """
344
295
 
345
296
  @app.listener("before_server_start")
346
- async def generate(app, loop):
297
+ async def audit_configuration(app, loop):
298
+ if config.SECRET == DEFAULT_CONFIG["SECRET"]:
299
+ warnings.warn("Secret should be changed from default.", AuditWarning, 2)
300
+ if not config.SESSION_HTTPONLY:
301
+ warnings.warn("HttpOnly should be enabled.", AuditWarning, 2)
302
+ if not config.SESSION_SECURE:
303
+ warnings.warn("Secure should be enabled.", AuditWarning, 2)
304
+ if not config.SESSION_SAMESITE or config.SESSION_SAMESITE.lower() == "none":
305
+ warnings.warn("SameSite should not be none.", AuditWarning, 2)
306
+ if not config.SESSION_DOMAIN:
307
+ warnings.warn("Domain should not be none.", AuditWarning, 2)
308
+ if (
309
+ create_root
310
+ and config.INITIAL_ADMIN_EMAIL == DEFAULT_CONFIG["INITIAL_ADMIN_EMAIL"]
311
+ ):
312
+ warnings.warn(
313
+ "Initial admin email should be changed from default.", AuditWarning, 2
314
+ )
315
+ if (
316
+ create_root
317
+ and config.INITIAL_ADMIN_PASSWORD
318
+ == DEFAULT_CONFIG["INITIAL_ADMIN_PASSWORD"]
319
+ ):
320
+ warnings.warn(
321
+ "Initial admin password should be changed from default.",
322
+ AuditWarning,
323
+ 2,
324
+ )
325
+
326
+ @app.listener("before_server_start")
327
+ async def create_root_account(app, loop):
328
+ if not create_root:
329
+ return
347
330
  try:
348
- role = await Role.filter(name="Head Admin").get()
331
+ role = await Role.filter(name="Root").get()
349
332
  except DoesNotExist:
350
333
  role = await Role.create(
351
- description="Has the ability to control any aspect of the API, assign sparingly.",
352
- permissions="*:*",
353
- name="Head Admin",
334
+ description="Has administrator abilities, assign sparingly.",
335
+ permissions=["*:*"],
336
+ name="Root",
354
337
  )
355
338
  try:
356
- account = await Account.filter(
357
- email=security_config.INITIAL_ADMIN_EMAIL
358
- ).get()
339
+ account = await Account.filter(email=config.INITIAL_ADMIN_EMAIL).get()
359
340
  await account.fetch_related("roles")
360
341
  if role not in account.roles:
361
342
  await account.roles.add(role)
362
- logger.warning(
363
- 'The initial admin account role "Head Admin" was removed and has been reinstated.'
364
- )
343
+ logger.warning("Initial admin account role has been reinstated.")
365
344
  except DoesNotExist:
366
345
  account = await Account.create(
367
- username="Head-Admin",
368
- email=security_config.INITIAL_ADMIN_EMAIL,
369
- password=PasswordHasher().hash(security_config.INITIAL_ADMIN_PASSWORD),
346
+ username="Root",
347
+ email=config.INITIAL_ADMIN_EMAIL,
348
+ password=password_hasher.hash(config.INITIAL_ADMIN_PASSWORD),
370
349
  verified=True,
371
350
  )
372
351
  await account.roles.add(role)
373
352
  logger.info("Initial admin account created.")
353
+
354
+ @app.on_response
355
+ async def session_middleware(request, response):
356
+ if hasattr(request.ctx, "session"):
357
+ if getattr(request.ctx.session, "is_refresh", False):
358
+ request.ctx.session.encode(response)
359
+ elif not request.ctx.session.active:
360
+ response.delete_cookie(
361
+ f"{config.SESSION_PREFIX}_{request.ctx.session.__class__.__name__[:7].lower()}"
362
+ )