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.
@@ -7,28 +7,30 @@ import httpx
7
7
  from sanic_security.configuration import Config
8
8
 
9
9
  """
10
- An effective, simple, and async security library for the Sanic framework.
11
- Copyright (C) 2020-present Aidan Stewart
12
-
13
- This program is free software: you can redistribute it and/or modify
14
- it under the terms of the GNU Affero General Public License as published
15
- by the Free Software Foundation, either version 3 of the License, or
16
- (at your option) any later version.
17
-
18
- This program is distributed in the hope that it will be useful,
19
- but WITHOUT ANY WARRANTY; without even the implied warranty of
20
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21
- GNU Affero General Public License for more details.
22
-
23
- You should have received a copy of the GNU Affero General Public License
24
- along with this program. If not, see <https://www.gnu.org/licenses/>.
10
+ Copyright (c) 2020-present Nicholas Aidan Stewart
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
25
29
  """
26
30
 
27
31
 
28
32
  class RegistrationTest(TestCase):
29
- """
30
- Registration tests.
31
- """
33
+ """Registration tests."""
32
34
 
33
35
  def setUp(self):
34
36
  self.client = httpx.Client()
@@ -58,9 +60,7 @@ class RegistrationTest(TestCase):
58
60
  return registration_response
59
61
 
60
62
  def test_registration(self):
61
- """
62
- Account registration and login.
63
- """
63
+ """Account registration and login."""
64
64
  registration_response = self.register(
65
65
  "account_registration@register.test",
66
66
  "account_registration",
@@ -76,9 +76,7 @@ class RegistrationTest(TestCase):
76
76
  assert login_response.status_code == 200, login_response.text
77
77
 
78
78
  def test_invalid_registration(self):
79
- """
80
- Registration with an intentionally invalid email, username, and phone.
81
- """
79
+ """Registration with an intentionally invalid email, username, and phone."""
82
80
  invalid_email_registration_response = self.register(
83
81
  "invalid_register.test", "invalid_register", False, True
84
82
  )
@@ -91,12 +89,6 @@ class RegistrationTest(TestCase):
91
89
  assert (
92
90
  invalid_phone_registration_response.status_code == 400
93
91
  ), invalid_phone_registration_response.text
94
- invalid_username_registration_response = self.register(
95
- "invalid_user@register.test", "_inVal!d_", False, True
96
- )
97
- assert (
98
- invalid_username_registration_response.status_code == 400
99
- ), invalid_username_registration_response.text
100
92
  too_many_characters_registration_response = self.register(
101
93
  "too_long_user@register.test",
102
94
  "this_username_is_too_long_to_be_registered_with",
@@ -108,9 +100,7 @@ class RegistrationTest(TestCase):
108
100
  ), too_many_characters_registration_response.text
109
101
 
110
102
  def test_registration_disabled(self):
111
- """
112
- Registration and login with a disabled account.
113
- """
103
+ """Registration and login with a disabled account."""
114
104
  registration_response = self.register(
115
105
  "disabled@register.test", "disabled", True, True
116
106
  )
@@ -122,9 +112,7 @@ class RegistrationTest(TestCase):
122
112
  assert "DisabledError" in login_response.text, login_response.text
123
113
 
124
114
  def test_registration_unverified(self):
125
- """
126
- Registration and login with an unverified account.
127
- """
115
+ """Registration and login with an unverified account."""
128
116
  registration_response = self.register(
129
117
  "unverified@register.test", "unverified", False, False
130
118
  )
@@ -136,9 +124,7 @@ class RegistrationTest(TestCase):
136
124
  assert "UnverifiedError" in login_response.text, login_response.text
137
125
 
138
126
  def test_registration_unverified_disabled(self):
139
- """
140
- Registration and login with an unverified and disabled account.
141
- """
127
+ """Registration and login with an unverified and disabled account."""
142
128
  registration_response = self.register(
143
129
  "unverified_disabled@register.test", "unverified_disabled", True, False
144
130
  )
@@ -151,9 +137,7 @@ class RegistrationTest(TestCase):
151
137
 
152
138
 
153
139
  class LoginTest(TestCase):
154
- """
155
- Login tests.
156
- """
140
+ """Login tests."""
157
141
 
158
142
  def setUp(self):
159
143
  self.client = httpx.Client()
@@ -162,9 +146,7 @@ class LoginTest(TestCase):
162
146
  self.client.close()
163
147
 
164
148
  def test_login(self):
165
- """
166
- Login with an email and password.
167
- """
149
+ """Login with an email and password."""
168
150
  self.client.post(
169
151
  "http://127.0.0.1:8000/api/test/account",
170
152
  data={"email": "email_pass@login.test", "username": "email_pass"},
@@ -180,9 +162,7 @@ class LoginTest(TestCase):
180
162
  assert authenticate_response.status_code == 200, authenticate_response.text
181
163
 
182
164
  def test_login_with_username(self):
183
- """
184
- Login with a username instead of an email and password.
185
- """
165
+ """Login with a username instead of an email and password."""
186
166
  self.client.post(
187
167
  "http://127.0.0.1:8000/api/test/account",
188
168
  data={"email": "user_pass@login.test", "username": "user_pass"},
@@ -198,9 +178,7 @@ class LoginTest(TestCase):
198
178
  assert authenticate_response.status_code == 200, authenticate_response.text
199
179
 
200
180
  def test_invalid_login(self):
201
- """
202
- Login with an intentionally incorrect password and into a non existent account.
203
- """
181
+ """Login with an intentionally incorrect password and into a non-existent account."""
204
182
  self.client.post(
205
183
  "http://127.0.0.1:8000/api/test/account",
206
184
  data={"email": "incorrect_pass@login.test", "username": "incorrect_pass"},
@@ -221,9 +199,7 @@ class LoginTest(TestCase):
221
199
  ), unavailable_account_login_response
222
200
 
223
201
  def test_logout(self):
224
- """
225
- Logout of logged in account and attempt to authenticate.
226
- """
202
+ """Logout of logged in account and attempt to authenticate."""
227
203
  self.client.post(
228
204
  "http://127.0.0.1:8000/api/test/account",
229
205
  data={"email": "logout@login.test", "username": "logout"},
@@ -240,9 +216,7 @@ class LoginTest(TestCase):
240
216
  assert authenticate_response.status_code == 401, authenticate_response.text
241
217
 
242
218
  def test_initial_admin_login(self):
243
- """
244
- Initial admin account login and authorization.
245
- """
219
+ """Initial admin account login and authorization."""
246
220
  login_response = self.client.post(
247
221
  "http://127.0.0.1:8000/api/test/auth/login",
248
222
  auth=("admin@login.test", "admin123"),
@@ -251,8 +225,8 @@ class LoginTest(TestCase):
251
225
  permitted_authorization_response = self.client.post(
252
226
  "http://127.0.0.1:8000/api/test/auth/roles",
253
227
  data={
254
- "role": "Head Admin",
255
- "permissions_required": "perm1:create,add, perm2:*",
228
+ "role": "Root",
229
+ "permissions_required": ["perm1:create,add", "perm2:*"],
256
230
  },
257
231
  )
258
232
  assert (
@@ -260,9 +234,7 @@ class LoginTest(TestCase):
260
234
  ), permitted_authorization_response.text
261
235
 
262
236
  def test_two_factor_login(self):
263
- """
264
- Test login with two-factor authentication requirement.
265
- """
237
+ """Test login with two-factor authentication requirement."""
266
238
  self.client.post(
267
239
  "http://127.0.0.1:8000/api/test/account",
268
240
  data={"email": "two-factor@login.test", "username": "two-factor"},
@@ -290,11 +262,22 @@ class LoginTest(TestCase):
290
262
  )
291
263
  assert authenticate_response.status_code == 200, authenticate_response.text
292
264
 
265
+ def test_anonymous_login(self):
266
+ """Test login of anonymous user."""
267
+ anon_login_response = self.client.post(
268
+ "http://127.0.0.1:8000/api/test/auth/login/anon"
269
+ )
270
+ assert anon_login_response.status_code == 200, anon_login_response.text
271
+ authenticate_response = self.client.post(
272
+ "http://127.0.0.1:8000/api/test/auth",
273
+ )
274
+ assert authenticate_response.status_code == 200, authenticate_response.text
275
+ logout_response = self.client.post("http://127.0.0.1:8000/api/test/auth/logout")
276
+ assert logout_response.status_code == 200, logout_response.text
277
+
293
278
 
294
279
  class VerificationTest(TestCase):
295
- """
296
- Two-step verification and captcha tests.
297
- """
280
+ """Two-step verification and captcha tests."""
298
281
 
299
282
  def setUp(self):
300
283
  self.client = httpx.Client()
@@ -303,9 +286,7 @@ class VerificationTest(TestCase):
303
286
  self.client.close()
304
287
 
305
288
  def test_captcha(self):
306
- """
307
- Captcha request and attempt.
308
- """
289
+ """Captcha request and attempt."""
309
290
  captcha_request_response = self.client.get(
310
291
  "http://127.0.0.1:8000/api/test/capt/request"
311
292
  )
@@ -325,9 +306,7 @@ class VerificationTest(TestCase):
325
306
  ), captcha_attempt_response.text
326
307
 
327
308
  def test_two_step_verification(self):
328
- """
329
- Two-step verification request and attempt.
330
- """
309
+ """Two-step verification request and attempt."""
331
310
  self.client.post(
332
311
  "http://127.0.0.1:8000/api/test/account",
333
312
  data={"email": "two_step@verification.test", "username": "two_step"},
@@ -346,26 +325,26 @@ class VerificationTest(TestCase):
346
325
  assert (
347
326
  two_step_verification_invalid_attempt_response.status_code == 401
348
327
  ), two_step_verification_invalid_attempt_response.text
328
+ two_step_verification_no_email_request_response = self.client.post(
329
+ "http://127.0.0.1:8000/api/test/two-step/request",
330
+ )
331
+ assert (
332
+ two_step_verification_no_email_request_response.status_code == 200
333
+ ), two_step_verification_no_email_request_response.text
349
334
  two_step_verification_attempt_response = self.client.post(
350
335
  "http://127.0.0.1:8000/api/test/two-step",
351
336
  data={
352
- "code": json.loads(two_step_verification_request_response.text)["data"]
337
+ "code": json.loads(
338
+ two_step_verification_no_email_request_response.text
339
+ )["data"]
353
340
  },
354
341
  )
355
342
  assert (
356
343
  two_step_verification_attempt_response.status_code == 200
357
344
  ), two_step_verification_attempt_response.text
358
- two_step_verification_no_email_request_response = self.client.post(
359
- "http://127.0.0.1:8000/api/test/two-step/request",
360
- )
361
- assert (
362
- two_step_verification_no_email_request_response.status_code == 200
363
- ), two_step_verification_no_email_request_response.text
364
345
 
365
346
  def test_account_verification(self):
366
- """
367
- Account registration and verification process with successful login.
368
- """
347
+ """Account registration and verification process with successful login."""
369
348
  registration_response = self.client.post(
370
349
  "http://127.0.0.1:8000/api/test/auth/register",
371
350
  data={
@@ -385,9 +364,7 @@ class VerificationTest(TestCase):
385
364
 
386
365
 
387
366
  class AuthorizationTest(TestCase):
388
- """
389
- Role and permissions based authorization tests.
390
- """
367
+ """Role and permissions based authorization tests."""
391
368
 
392
369
  def setUp(self):
393
370
  self.client = httpx.Client()
@@ -396,9 +373,7 @@ class AuthorizationTest(TestCase):
396
373
  self.client.close()
397
374
 
398
375
  def test_permissions_authorization(self):
399
- """
400
- Authorization with permissions.
401
- """
376
+ """Authorization with permissions."""
402
377
  self.client.post(
403
378
  "http://127.0.0.1:8000/api/test/account",
404
379
  data={"email": "permissions@authorization.test", "username": "permissions"},
@@ -411,24 +386,45 @@ class AuthorizationTest(TestCase):
411
386
  "http://127.0.0.1:8000/api/test/auth/roles/assign",
412
387
  data={
413
388
  "name": "AuthTestPerms",
414
- "permissions": "perm1:create,add, perm2:delete",
389
+ "permissions": "perm1:create,update, perm2:delete,retrieve, perm3:*",
390
+ },
391
+ )
392
+ permitted_authorization_response = self.client.post(
393
+ "http://127.0.0.1:8000/api/test/auth/roles",
394
+ data={
395
+ "role": "AuthTestPerms",
396
+ "permissions_required": "perm1:create,update, perm3:retrieve",
415
397
  },
416
398
  )
399
+ assert (
400
+ permitted_authorization_response.status_code == 200
401
+ ), permitted_authorization_response.text
417
402
  permitted_authorization_response = self.client.post(
418
403
  "http://127.0.0.1:8000/api/test/auth/roles",
419
404
  data={
420
405
  "role": "AuthTestPerms",
421
- "permissions_required": "perm1:create,add, perm2:*",
406
+ "permissions_required": "perm1:retrieve, perm2:delete",
422
407
  },
423
408
  )
424
409
  assert (
425
410
  permitted_authorization_response.status_code == 200
426
411
  ), permitted_authorization_response.text
412
+
413
+ prohibited_authorization_response = self.client.post(
414
+ "http://127.0.0.1:8000/api/test/auth/roles",
415
+ data={
416
+ "role": "AuthTestPerms",
417
+ "permissions_required": "perm1:create,retrieve",
418
+ },
419
+ )
420
+ assert (
421
+ prohibited_authorization_response.status_code == 403
422
+ ), prohibited_authorization_response.text
427
423
  prohibited_authorization_response = self.client.post(
428
424
  "http://127.0.0.1:8000/api/test/auth/roles",
429
425
  data={
430
426
  "role": "AuthTestPerms",
431
- "permissions_required": "perm2:add, perm1:delete",
427
+ "permissions_required": "perm1:delete, perm2:create",
432
428
  },
433
429
  )
434
430
  assert (
@@ -436,9 +432,7 @@ class AuthorizationTest(TestCase):
436
432
  ), prohibited_authorization_response.text
437
433
 
438
434
  def test_roles_authorization(self):
439
- """
440
- Authorization with roles.
441
- """
435
+ """Authorization with roles."""
442
436
  self.client.post(
443
437
  "http://127.0.0.1:8000/api/test/account",
444
438
  data={"email": "roles@authorization.test", "username": "roles"},
@@ -468,11 +462,27 @@ class AuthorizationTest(TestCase):
468
462
  prohibited_authorization_response.status_code == 403
469
463
  ), prohibited_authorization_response.text
470
464
 
465
+ def test_anonymous_authorization(self):
466
+ """Authorization with anonymous client."""
467
+ anon_login_response = self.client.post(
468
+ "http://127.0.0.1:8000/api/test/auth/login/anon"
469
+ )
470
+ assert anon_login_response.status_code == 200, anon_login_response.text
471
+ authenticate_response = self.client.post(
472
+ "http://127.0.0.1:8000/api/test/auth",
473
+ )
474
+ assert authenticate_response.status_code == 200, authenticate_response.text
475
+ prohibited_authorization_response = self.client.post(
476
+ "http://127.0.0.1:8000/api/test/auth/roles",
477
+ data={"role": "AuthTestPerms"},
478
+ )
479
+ assert (
480
+ prohibited_authorization_response.status_code == 403
481
+ ), prohibited_authorization_response.text
482
+
471
483
 
472
484
  class MiscTest(TestCase):
473
- """
474
- Miscellaneous tests that cannot be categorized.
475
- """
485
+ """Miscellaneous tests that cannot be categorized."""
476
486
 
477
487
  def setUp(self):
478
488
  self.client = httpx.Client()
@@ -481,18 +491,14 @@ class MiscTest(TestCase):
481
491
  self.client.close()
482
492
 
483
493
  def test_environment_variable_load(self):
484
- """
485
- Config loads environment variables.
486
- """
494
+ """Config loads environment variables."""
487
495
  os.environ["SANIC_SECURITY_SECRET"] = "test-secret"
488
496
  security_config = Config()
489
497
  security_config.load_environment_variables()
490
498
  assert security_config.SECRET == "test-secret"
491
499
 
492
500
  def test_get_associated_sessions(self):
493
- """
494
- Retrieve sessions associated to logged in account.
495
- """
501
+ """Retrieve sessions associated to logged in account."""
496
502
  self.client.post(
497
503
  "http://127.0.0.1:8000/api/test/account",
498
504
  data={
@@ -505,9 +511,37 @@ class MiscTest(TestCase):
505
511
  auth=("get_associated_sessions@misc.test", "password"),
506
512
  )
507
513
  assert login_response.status_code == 200, login_response.text
508
- retrieve_associated_response = self.client.post(
514
+ retrieve_associated_response = self.client.get(
509
515
  "http://127.0.0.1:8000/api/test/auth/associated"
510
516
  )
511
517
  assert (
512
518
  retrieve_associated_response.status_code == 200
513
519
  ), retrieve_associated_response.text
520
+
521
+ def test_authentication_refresh(self):
522
+ """Test automatic authentication refresh."""
523
+ self.client.post(
524
+ "http://127.0.0.1:8000/api/test/account",
525
+ data={
526
+ "email": "refreshed@misc.test",
527
+ "username": "refreshed",
528
+ },
529
+ )
530
+ login_response = self.client.post(
531
+ "http://127.0.0.1:8000/api/test/auth/login",
532
+ auth=("refreshed@misc.test", "password"),
533
+ )
534
+ assert login_response.status_code == 200, login_response.text
535
+ expire_response = self.client.post("http://127.0.0.1:8000/api/test/auth/expire")
536
+ assert expire_response.status_code == 200, expire_response.text
537
+ authenticate_refresh_response = self.client.post(
538
+ "http://127.0.0.1:8000/api/test/auth",
539
+ )
540
+ assert (
541
+ authenticate_refresh_response.status_code == 200
542
+ ), authenticate_refresh_response.text
543
+ assert json.loads(authenticate_refresh_response.text)["data"]["refresh"] is True
544
+ authenticate_response = self.client.post(
545
+ "http://127.0.0.1:8000/api/test/auth",
546
+ ) # Since session refresh handling is complete, it will be returned as a regular session now.
547
+ assert authenticate_response.status_code == 200, authenticate_response.text
sanic_security/utils.py CHANGED
@@ -1,29 +1,44 @@
1
1
  import datetime
2
2
  import random
3
- import string
3
+ from string import ascii_uppercase, digits
4
4
 
5
+ from argon2 import PasswordHasher
6
+ from captcha.audio import AudioCaptcha
7
+ from captcha.image import ImageCaptcha
5
8
  from sanic.request import Request
6
9
  from sanic.response import json as sanic_json, HTTPResponse
10
+ from sanic.utils import str_to_bool as sanic_str_to_bool
7
11
 
12
+ from sanic_security.configuration import config
8
13
 
9
14
  """
10
- An effective, simple, and async security library for the Sanic framework.
11
- Copyright (C) 2020-present Aidan Stewart
12
-
13
- This program is free software: you can redistribute it and/or modify
14
- it under the terms of the GNU Affero General Public License as published
15
- by the Free Software Foundation, either version 3 of the License, or
16
- (at your option) any later version.
17
-
18
- This program is distributed in the hope that it will be useful,
19
- but WITHOUT ANY WARRANTY; without even the implied warranty of
20
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21
- GNU Affero General Public License for more details.
22
-
23
- You should have received a copy of the GNU Affero General Public License
24
- along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+ Copyright (c) 2020-Present Nicholas Aidan Stewart
16
+
17
+ Permission is hereby granted, free of charge, to any person obtaining a copy
18
+ of this software and associated documentation files (the "Software"), to deal
19
+ in the Software without restriction, including without limitation the rights
20
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
+ copies of the Software, and to permit persons to whom the Software is
22
+ furnished to do so, subject to the following conditions:
23
+
24
+ The above copyright notice and this permission notice shall be included in all
25
+ copies or substantial portions of the Software.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
+ SOFTWARE.
25
34
  """
26
35
 
36
+ image_generator: ImageCaptcha = ImageCaptcha(
37
+ 190, 90, fonts=config.CAPTCHA_FONT.replace(" ", "").split(",")
38
+ )
39
+ audio_generator: AudioCaptcha = AudioCaptcha(voicedir=config.CAPTCHA_VOICE)
40
+ password_hasher: PasswordHasher = PasswordHasher()
41
+
27
42
 
28
43
  def get_ip(request: Request) -> str:
29
44
  """
@@ -38,31 +53,33 @@ def get_ip(request: Request) -> str:
38
53
  return request.remote_addr or request.ip
39
54
 
40
55
 
41
- def get_code() -> str:
56
+ def get_code(digits_only: bool = False) -> str:
42
57
  """
43
58
  Generates random code to be used for verification.
44
59
 
60
+ Args:
61
+ digits_only: Determines if code should only contain digits.
62
+
45
63
  Returns:
46
64
  code
47
65
  """
48
- return "".join(random.choices(string.digits + string.ascii_uppercase, k=6))
66
+ return "".join(
67
+ random.choice(("" if digits_only else ascii_uppercase) + digits)
68
+ for _ in range(6)
69
+ )
49
70
 
50
71
 
51
- def json(message: str, data, status_code: int = 200) -> HTTPResponse:
72
+ def is_expired(date):
52
73
  """
53
- A preformatted Sanic json response.
74
+ Checks if current date has surpassed the date passed into the function.
54
75
 
55
76
  Args:
56
- message (int): Message describing data or relaying human-readable information.
57
- data (Any): Raw information to be used by client.
58
- status_code (int): HTTP response code.
77
+ date: The date being checked for expiration.
59
78
 
60
79
  Returns:
61
- json
80
+ is_expired
62
81
  """
63
- return sanic_json(
64
- {"message": message, "code": status_code, "data": data}, status=status_code
65
- )
82
+ return date and datetime.datetime.now(datetime.timezone.utc) >= date
66
83
 
67
84
 
68
85
  def get_expiration_date(seconds: int) -> datetime.datetime:
@@ -76,7 +93,29 @@ def get_expiration_date(seconds: int) -> datetime.datetime:
76
93
  expiration_date
77
94
  """
78
95
  return (
79
- datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds)
96
+ datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=seconds)
80
97
  if seconds > 0
81
98
  else None
82
99
  )
100
+
101
+
102
+ def str_to_bool(val: str) -> bool:
103
+ """Returns false if val is None instead of raising ValueError (Sanic's implementation)."""
104
+ return sanic_str_to_bool(val) if val else False
105
+
106
+
107
+ def json(message: str, data, status_code: int = 200) -> HTTPResponse:
108
+ """
109
+ A preformatted Sanic json response.
110
+
111
+ Args:
112
+ message (str): Message describing data or relaying human-readable information.
113
+ data (Any): Raw information to be used by client.
114
+ status_code (int): HTTP response code.
115
+
116
+ Returns:
117
+ json
118
+ """
119
+ return sanic_json(
120
+ {"message": message, "code": status_code, "data": data}, status=status_code
121
+ )