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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ )