sanic-security 1.15.2__py3-none-any.whl → 1.16.0__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.
@@ -0,0 +1,239 @@
1
+ import functools
2
+ import time
3
+ from typing import Literal
4
+
5
+ import jwt
6
+ from httpx_oauth.oauth2 import BaseOAuth2, RefreshTokenError
7
+ from jwt import DecodeError
8
+ from sanic import Request, HTTPResponse, Sanic
9
+ from sanic.log import logger
10
+ from tortoise.exceptions import IntegrityError, DoesNotExist
11
+
12
+ from sanic_security.configuration import config
13
+ from sanic_security.exceptions import JWTDecodeError, ExpiredError, CredentialsError
14
+ from sanic_security.models import Account, AuthenticationSession
15
+ from sanic_security.utils import get_ip
16
+
17
+ """
18
+ Copyright (c) 2020-present Nicholas Aidan Stewart
19
+
20
+ Permission is hereby granted, free of charge, to any person obtaining a copy
21
+ of this software and associated documentation files (the "Software"), to deal
22
+ in the Software without restriction, including without limitation the rights
23
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24
+ copies of the Software, and to permit persons to whom the Software is
25
+ furnished to do so, subject to the following conditions:
26
+
27
+ The above copyright notice and this permission notice shall be included in all
28
+ copies or substantial portions of the Software.
29
+
30
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36
+ SOFTWARE.
37
+ """
38
+
39
+
40
+ async def oauth_url(
41
+ client: BaseOAuth2,
42
+ redirect_uri: str = config.OAUTH_REDIRECT,
43
+ state: str = None,
44
+ scopes: list[str] = None,
45
+ code_challenge: str = None,
46
+ code_challenge_method: Literal["plain", "S256"] = None,
47
+ **extra_params: str,
48
+ ) -> str:
49
+ """
50
+ Constructs an authorization URL to prompt the user to authorize the application.
51
+
52
+ Args:
53
+ client (BaseOAuth2): OAuth provider.
54
+ redirect_uri (str): The URL where the user will be redirected after authorization.
55
+ state (str): An opaque value used by the client to maintain state between the request and the callback.
56
+ scopes (list[str]): The scopes to be requested. If not provided, `base_scopes` will be used.
57
+ code_challenge (str): [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) code challenge.
58
+ code_challenge_method (str): [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) code challenge method.
59
+ **extra_params (dict[str, str]): Optional extra parameters specific to the service.
60
+
61
+ Returns:
62
+ oauth_redirect
63
+ """
64
+ return await client.get_authorization_url(
65
+ redirect_uri,
66
+ state,
67
+ scopes or client.base_scopes,
68
+ code_challenge,
69
+ code_challenge_method,
70
+ extra_params,
71
+ )
72
+
73
+
74
+ async def oauth_callback(
75
+ request: Request,
76
+ client: BaseOAuth2,
77
+ redirect_uri: str = config.OAUTH_REDIRECT,
78
+ code_verifier: str = None,
79
+ ) -> tuple[dict, AuthenticationSession]:
80
+ """
81
+ Requests an access token using the authorization code obtained after the user has authorized the application.
82
+ An account is retrieved if it already exists, created if it doesn't, and the user is logged in.
83
+
84
+ Args:
85
+ request (Request): Sanic request parameter.
86
+ client (BaseOAuth2): OAuth provider.
87
+ redirect_uri (str): The URL where the user was redirected after authorization.
88
+ code_verifier (str): Optional code verifier used in the [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) flow.
89
+
90
+ Raises:
91
+ CredentialsError
92
+ GetAccessTokenError
93
+ GetIdEmailError
94
+
95
+ Returns:
96
+ oauth_redirect
97
+ """
98
+ token_info = await client.get_access_token(
99
+ request.args.get("code"),
100
+ redirect_uri,
101
+ code_verifier,
102
+ )
103
+ if "expires_at" not in token_info:
104
+ token_info["expires_at"] = time.time() + token_info["expires_in"]
105
+ oauth_id, email = await client.get_id_email(token_info["access_token"])
106
+ try:
107
+ try:
108
+ account = await Account.get(oauth_id=oauth_id)
109
+ except DoesNotExist:
110
+ account = await Account.create(
111
+ email=email,
112
+ username=email.split("@")[0],
113
+ password="",
114
+ oauth_id=oauth_id,
115
+ )
116
+ authentication_session = await AuthenticationSession.new(
117
+ request,
118
+ account,
119
+ )
120
+ logger.info(
121
+ f"Client {get_ip(request)} has logged in via {client.__class__.__name__} with authentication session {authentication_session.id}."
122
+ )
123
+ return token_info, authentication_session
124
+ except IntegrityError:
125
+ raise CredentialsError(
126
+ f"Account may not be linked to this OAuth provider if it already exists.",
127
+ 409,
128
+ )
129
+
130
+
131
+ def oauth_encode(response: HTTPResponse, token_info: dict) -> None:
132
+ """
133
+ Transforms OAuth access token into JWT and then is stored in a cookie.
134
+
135
+ Args:
136
+ response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
137
+ token_info (dict): OAuth access token.
138
+ """
139
+ response.cookies.add_cookie(
140
+ f"{config.SESSION_PREFIX}_oauth",
141
+ str(
142
+ jwt.encode(
143
+ token_info,
144
+ config.SECRET,
145
+ config.SESSION_ENCODING_ALGORITHM,
146
+ ),
147
+ ),
148
+ httponly=config.SESSION_HTTPONLY,
149
+ samesite=config.SESSION_SAMESITE,
150
+ secure=config.SESSION_SECURE,
151
+ domain=config.SESSION_DOMAIN,
152
+ max_age=token_info["expires_in"] + config.AUTHENTICATION_REFRESH_EXPIRATION,
153
+ )
154
+
155
+
156
+ async def oauth_decode(request: Request, client: BaseOAuth2, refresh=False) -> dict:
157
+ """
158
+ Decodes OAuth JWT token from client cookie into an access token.
159
+
160
+ Args:
161
+ request (Request): Sanic request parameter.
162
+ client (BaseOAuth2): OAuth provider.
163
+ refresh (bool): Ensures that the decoded access token is refreshed.
164
+
165
+ Raises:
166
+ JWTDecodeError
167
+ ExpiredError
168
+ RefreshTokenNotSupportedError
169
+
170
+ Returns:
171
+ token_info
172
+ """
173
+ try:
174
+ token_info = jwt.decode(
175
+ request.cookies.get(
176
+ f"{config.SESSION_PREFIX}_oauth",
177
+ ),
178
+ config.PUBLIC_SECRET or config.SECRET,
179
+ config.SESSION_ENCODING_ALGORITHM,
180
+ )
181
+ if time.time() > token_info["expires_at"] or refresh:
182
+ token_info = await client.refresh_token(token_info["refresh_token"])
183
+ token_info["is_refresh"] = True
184
+ if "expires_at" not in token_info:
185
+ token_info["expires_at"] = time.time() + token_info["expires_in"]
186
+ request.ctx.oauth = token_info
187
+ return token_info
188
+ except RefreshTokenError:
189
+ raise ExpiredError
190
+ except DecodeError:
191
+ raise JWTDecodeError
192
+
193
+
194
+ def requires_oauth(client: BaseOAuth2):
195
+ """
196
+ Decodes OAuth JWT token from client cookie into an access token.
197
+
198
+ Args:
199
+ client (BaseOAuth2): OAuth provider.
200
+
201
+ Example:
202
+ This method is not called directly and instead used as a decorator:
203
+
204
+ @app.post('api/oauth')
205
+ @requires_oauth
206
+ async def on_oauth(request):
207
+ return text('OAuth access token retrieved!')
208
+
209
+ Raises:
210
+ JWTDecodeError
211
+ ExpiredError
212
+ RefreshTokenNotSupportedError
213
+ """
214
+
215
+ def decorator(func):
216
+ @functools.wraps(func)
217
+ async def wrapper(request, *args, **kwargs):
218
+ await oauth_decode(request, client)
219
+ return await func(request, *args, **kwargs)
220
+
221
+ return wrapper
222
+
223
+ return decorator
224
+
225
+
226
+ def initialize_oauth(app: Sanic) -> None:
227
+ """
228
+ Attaches refresh encoder middleware.
229
+
230
+ Args:
231
+ app (Sanic): Sanic application instance.
232
+ """
233
+
234
+ @app.on_response
235
+ async def refresh_encoder_middleware(request, response):
236
+ if hasattr(request.ctx, "oauth") and getattr(
237
+ request.ctx.oauth, "is_refresh", False
238
+ ):
239
+ oauth_encode(response, request.ctx.oauth)
@@ -1,8 +1,8 @@
1
1
  import datetime
2
2
  import traceback
3
3
 
4
- from argon2 import PasswordHasher
5
- from sanic import Sanic, text
4
+ from httpx_oauth.clients.google import GoogleOAuth2
5
+ from sanic import Sanic, text, raw, redirect
6
6
  from tortoise.contrib.sanic import register_tortoise
7
7
 
8
8
  from sanic_security.authentication import (
@@ -18,10 +18,17 @@ from sanic_security.authorization import (
18
18
  check_permissions,
19
19
  check_roles,
20
20
  )
21
- from sanic_security.configuration import config as security_config
21
+ from sanic_security.configuration import config
22
22
  from sanic_security.exceptions import SecurityError
23
23
  from sanic_security.models import Account, CaptchaSession, AuthenticationSession
24
- from sanic_security.utils import json
24
+ from sanic_security.oauth import (
25
+ oauth_encode,
26
+ initialize_oauth,
27
+ oauth_url,
28
+ oauth_callback,
29
+ oauth_decode,
30
+ )
31
+ from sanic_security.utils import json, str_to_bool, password_hasher
25
32
  from sanic_security.verification import (
26
33
  request_two_step_verification,
27
34
  requires_two_step_verification,
@@ -52,11 +59,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
52
59
  SOFTWARE.
53
60
  """
54
61
 
55
- app = Sanic("sanic-security-test")
56
- password_hasher = PasswordHasher()
57
-
58
-
59
- # TODO: Testing for new functionality.
62
+ app = Sanic("tests")
63
+ google_oauth = GoogleOAuth2(config.OAUTH_CLIENT, config.OAUTH_SECRET)
60
64
 
61
65
 
62
66
  @app.post("api/test/auth/register")
@@ -64,8 +68,8 @@ async def on_register(request):
64
68
  """Register an account with email and password."""
65
69
  account = await register(
66
70
  request,
67
- verified=request.form.get("verified") == "true",
68
- disabled=request.form.get("disabled") == "true",
71
+ verified=str_to_bool(request.form.get("verified")),
72
+ disabled=str_to_bool(request.form.get("disabled")),
69
73
  )
70
74
  if not account.verified:
71
75
  two_step_session = await request_two_step_verification(request, account)
@@ -90,11 +94,13 @@ async def on_verify(request):
90
94
  @app.post("api/test/auth/login")
91
95
  async def on_login(request):
92
96
  """Login to an account with an email and password."""
93
- two_factor_authentication = request.args.get("two-factor-authentication") == "true"
94
97
  authentication_session = await login(
95
- request, require_second_factor=two_factor_authentication
98
+ request,
99
+ require_second_factor=str_to_bool(
100
+ request.args.get("two-factor-authentication")
101
+ ),
96
102
  )
97
- if two_factor_authentication:
103
+ if str_to_bool(request.args.get("two-factor-authentication")):
98
104
  two_step_session = await request_two_step_verification(
99
105
  request, authentication_session.bearer
100
106
  )
@@ -192,14 +198,14 @@ async def on_captcha_request(request):
192
198
  async def on_captcha_image(request):
193
199
  """Request captcha image."""
194
200
  captcha_session = await CaptchaSession.decode(request)
195
- return captcha_session.get_image()
201
+ return raw(captcha_session.get_image(), content_type="image/jpeg")
196
202
 
197
203
 
198
204
  @app.get("api/test/capt/audio")
199
205
  async def on_captcha_audio(request):
200
206
  """Request captcha audio."""
201
207
  captcha_session = await CaptchaSession.decode(request)
202
- return captcha_session.get_audio()
208
+ return raw(captcha_session.get_audio(), content_type="audio/mpeg")
203
209
 
204
210
 
205
211
  @app.post("api/test/capt")
@@ -226,7 +232,6 @@ async def on_verification_attempt(request):
226
232
 
227
233
 
228
234
  @app.post("api/test/auth/roles")
229
- @requires_authentication
230
235
  async def on_authorization(request):
231
236
  """Check if client is authorized with sufficient roles and permissions."""
232
237
  await check_roles(request, request.form.get("role"))
@@ -234,7 +239,7 @@ async def on_authorization(request):
234
239
  await check_permissions(
235
240
  request, *request.form.get("permissions_required").split(", ")
236
241
  )
237
- return text("Account permitted.")
242
+ return text("Account permitted!")
238
243
 
239
244
 
240
245
  @app.post("api/test/auth/roles/assign")
@@ -244,10 +249,14 @@ async def on_role_assign(request):
244
249
  await assign_role(
245
250
  request.form.get("name"),
246
251
  request.ctx.session.bearer,
247
- request.form.get("permissions"),
248
252
  "Role used for testing.",
253
+ *(
254
+ request.form.get("permissions").split(", ")
255
+ if request.form.get("permissions")
256
+ else []
257
+ ),
249
258
  )
250
- return text("Role assigned.b")
259
+ return text("Role assigned!")
251
260
 
252
261
 
253
262
  @app.post("api/test/account")
@@ -255,7 +264,7 @@ async def on_account_creation(request):
255
264
  """Quick account creation."""
256
265
  account = await Account.create(
257
266
  username=request.form.get("username"),
258
- email=request.form.get("email").lower(),
267
+ email=request.form.get("email"),
259
268
  password=password_hasher.hash("password"),
260
269
  verified=True,
261
270
  disabled=False,
@@ -264,6 +273,43 @@ async def on_account_creation(request):
264
273
  return response
265
274
 
266
275
 
276
+ @app.get("api/test/oauth")
277
+ async def on_oauth_request(request):
278
+ """OAuth request."""
279
+ return redirect(
280
+ await oauth_url(
281
+ google_oauth,
282
+ "http://localhost:8000/api/test/oauth/callback",
283
+ )
284
+ )
285
+
286
+
287
+ @app.get("api/test/oauth/callback")
288
+ async def on_oauth_callback(request):
289
+ """OAuth callback."""
290
+ token_info, authentication_session = await oauth_callback(
291
+ request,
292
+ google_oauth,
293
+ "http://localhost:8000/api/test/oauth/callback",
294
+ )
295
+ response = json(
296
+ "OAuth successful.",
297
+ {"token_info": token_info, "auth_session": authentication_session.json},
298
+ )
299
+ oauth_encode(response, token_info)
300
+ authentication_session.encode(response)
301
+ return response
302
+
303
+
304
+ @app.get("api/test/oauth/token")
305
+ async def on_oauth_token(request):
306
+ """OAuth token retrieval."""
307
+ token_info = await oauth_decode(
308
+ request, google_oauth, str_to_bool(request.args.get("refresh"))
309
+ )
310
+ return json("Access token retrieved!", token_info)
311
+
312
+
267
313
  @app.exception(SecurityError)
268
314
  async def on_security_error(request, exception):
269
315
  """Handles security errors with correct response."""
@@ -271,7 +317,7 @@ async def on_security_error(request, exception):
271
317
  return exception.json
272
318
 
273
319
 
274
- security_config.SECRET = """
320
+ config.SECRET = """
275
321
  -----BEGIN RSA PRIVATE KEY-----
276
322
  MIIEpAIBAAKCAQEAww3pEiUx6wMFawJNAHCI80Qj3eyrP6Yx3LNNluQZXMyZkd+6ugBN9e1hw7v2z2PwmJENhYrqbBHU4vHCHEEZjdZIQRqwriFpeeoqMA1
277
323
  ecgwJz3fOuYo6WrUbS6pEyJ9vtjh5TaeZLzER+KIK2uvsjsQnFVt41hh3Xd+tR9p+QXT8aRep9hp4XLF87QlDVDrZIStfVn25+ZfSfKH+WYBUglZBmz/K6uW
@@ -289,7 +335,7 @@ mQ4BjbU1slel/eXlhomQpxoBCH3J/Ba9qd+uBql29QZMQXtKFg/mryjprapq8sUcbgazr9u1x+zJz9w+
289
335
  1G1CHHo/vq8zPNkVWmhciIUeHR3YJbw==
290
336
  -----END RSA PRIVATE KEY-----
291
337
  """
292
- security_config.PUBLIC_SECRET = """
338
+ config.PUBLIC_SECRET = """
293
339
  -----BEGIN PUBLIC KEY-----
294
340
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAww3pEiUx6wMFawJNAHCI80Qj3eyrP6Yx3LNNluQZXMyZkd+6ugBN9e1hw7v2z2PwmJENhYrqbBHU
295
341
  4vHCHEEZjdZIQRqwriFpeeoqMA1ecgwJz3fOuYo6WrUbS6pEyJ9vtjh5TaeZLzER+KIK2uvsjsQnFVt41hh3Xd+tR9p+QXT8aRep9hp4XLF87QlDVDrZIStf
@@ -297,16 +343,17 @@ Vn25+ZfSfKH+WYBUglZBmz/K6uW41mSRuuH3Pu/lnPgGvsxtT7KE8dkbyrI+Tyg0pniOYdxBxgpu06S6
297
343
  MHlkstd6FFYu5lJQcuppOm79iQIDAQAB
298
344
  -----END PUBLIC KEY-----
299
345
  """
300
- security_config.INITIAL_ADMIN_EMAIL = "admin@login.test"
301
- security_config.SESSION_ENCODING_ALGORITHM = "RS256"
302
- security_config.ALLOW_LOGIN_WITH_USERNAME = True
303
- security_config.SESSION_SECURE = False
346
+ config.INITIAL_ADMIN_EMAIL = "admin@login.test"
347
+ config.SESSION_ENCODING_ALGORITHM = "RS256"
348
+ config.ALLOW_LOGIN_WITH_USERNAME = True
349
+ config.SESSION_SECURE = False
304
350
  register_tortoise(
305
351
  app,
306
- db_url=security_config.TEST_DATABASE_URL,
352
+ db_url=config.TEST_DATABASE_URL,
307
353
  modules={"models": ["sanic_security.models"]},
308
354
  generate_schemas=True,
309
355
  )
310
356
  initialize_security(app, True)
357
+ initialize_oauth(app)
311
358
  if __name__ == "__main__":
312
359
  app.run(host="127.0.0.1", port=8000, workers=1, debug=True)
@@ -89,12 +89,6 @@ class RegistrationTest(TestCase):
89
89
  assert (
90
90
  invalid_phone_registration_response.status_code == 400
91
91
  ), invalid_phone_registration_response.text
92
- invalid_username_registration_response = self.register(
93
- "invalid_user@register.test", "_inVal!d_", False, True
94
- )
95
- assert (
96
- invalid_username_registration_response.status_code == 400
97
- ), invalid_username_registration_response.text
98
92
  too_many_characters_registration_response = self.register(
99
93
  "too_long_user@register.test",
100
94
  "this_username_is_too_long_to_be_registered_with",
@@ -232,7 +226,7 @@ class LoginTest(TestCase):
232
226
  "http://127.0.0.1:8000/api/test/auth/roles",
233
227
  data={
234
228
  "role": "Root",
235
- "permissions_required": "perm1:create,add, perm2:*",
229
+ "permissions_required": ["perm1:create,add", "perm2:*"],
236
230
  },
237
231
  )
238
232
  assert (
@@ -390,24 +384,45 @@ class AuthorizationTest(TestCase):
390
384
  "http://127.0.0.1:8000/api/test/auth/roles/assign",
391
385
  data={
392
386
  "name": "AuthTestPerms",
393
- "permissions": "perm1:create,add, perm2:delete",
387
+ "permissions": "perm1:create,update, perm2:delete,retrieve, perm3:*",
394
388
  },
395
389
  )
396
390
  permitted_authorization_response = self.client.post(
397
391
  "http://127.0.0.1:8000/api/test/auth/roles",
398
392
  data={
399
393
  "role": "AuthTestPerms",
400
- "permissions_required": "perm1:create,add, perm2:*",
394
+ "permissions_required": "perm1:create,update, perm3:retrieve",
395
+ },
396
+ )
397
+ assert (
398
+ permitted_authorization_response.status_code == 200
399
+ ), permitted_authorization_response.text
400
+ permitted_authorization_response = self.client.post(
401
+ "http://127.0.0.1:8000/api/test/auth/roles",
402
+ data={
403
+ "role": "AuthTestPerms",
404
+ "permissions_required": "perm1:retrieve, perm2:delete",
401
405
  },
402
406
  )
403
407
  assert (
404
408
  permitted_authorization_response.status_code == 200
405
409
  ), permitted_authorization_response.text
410
+
411
+ prohibited_authorization_response = self.client.post(
412
+ "http://127.0.0.1:8000/api/test/auth/roles",
413
+ data={
414
+ "role": "AuthTestPerms",
415
+ "permissions_required": "perm1:create,retrieve",
416
+ },
417
+ )
418
+ assert (
419
+ prohibited_authorization_response.status_code == 403
420
+ ), prohibited_authorization_response.text
406
421
  prohibited_authorization_response = self.client.post(
407
422
  "http://127.0.0.1:8000/api/test/auth/roles",
408
423
  data={
409
424
  "role": "AuthTestPerms",
410
- "permissions_required": "perm2:add, perm1:delete",
425
+ "permissions_required": "perm1:delete, perm2:create",
411
426
  },
412
427
  )
413
428
  assert (
sanic_security/utils.py CHANGED
@@ -7,6 +7,7 @@ from captcha.audio import AudioCaptcha
7
7
  from captcha.image import ImageCaptcha
8
8
  from sanic.request import Request
9
9
  from sanic.response import json as sanic_json, HTTPResponse
10
+ from sanic.utils import str_to_bool as sanic_str_to_bool
10
11
 
11
12
  from sanic_security.configuration import config
12
13
 
@@ -32,11 +33,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
33
  SOFTWARE.
33
34
  """
34
35
 
35
- image_generator = ImageCaptcha(
36
+ image_generator: ImageCaptcha = ImageCaptcha(
36
37
  190, 90, fonts=config.CAPTCHA_FONT.replace(" ", "").split(",")
37
38
  )
38
- audio_generator = AudioCaptcha(voicedir=config.CAPTCHA_VOICE)
39
- password_hasher = PasswordHasher()
39
+ audio_generator: AudioCaptcha = AudioCaptcha(voicedir=config.CAPTCHA_VOICE)
40
+ password_hasher: PasswordHasher = PasswordHasher()
40
41
 
41
42
 
42
43
  def get_ip(request: Request) -> str:
@@ -98,14 +99,17 @@ def get_expiration_date(seconds: int) -> datetime.datetime:
98
99
  )
99
100
 
100
101
 
101
- def json(
102
- message: str, data, status_code: int = 200
103
- ) -> HTTPResponse: # May be causing fixture error bc of json property
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:
104
108
  """
105
109
  A preformatted Sanic json response.
106
110
 
107
111
  Args:
108
- message (int): Message describing data or relaying human-readable information.
112
+ message (str): Message describing data or relaying human-readable information.
109
113
  data (Any): Raw information to be used by client.
110
114
  status_code (int): HTTP response code.
111
115
 
@@ -264,7 +264,4 @@ def requires_captcha(arg=None):
264
264
 
265
265
  return wrapper
266
266
 
267
- if callable(arg):
268
- return decorator(arg)
269
- else:
270
- return decorator
267
+ return decorator(arg) if callable(arg) else decorator