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.
@@ -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
+ )