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.
- sanic_security/authentication.py +18 -23
- sanic_security/authorization.py +44 -18
- sanic_security/configuration.py +9 -0
- sanic_security/exceptions.py +5 -3
- sanic_security/models.py +73 -70
- sanic_security/oauth.py +239 -0
- sanic_security/test/server.py +75 -28
- sanic_security/test/tests.py +25 -10
- sanic_security/utils.py +11 -7
- sanic_security/verification.py +1 -4
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/METADATA +133 -47
- sanic_security-1.16.0.dist-info/RECORD +17 -0
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/WHEEL +1 -1
- sanic_security-1.15.2.dist-info/RECORD +0 -16
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/LICENSE +0 -0
- {sanic_security-1.15.2.dist-info → sanic_security-1.16.0.dist-info}/top_level.txt +0 -0
sanic_security/oauth.py
ADDED
@@ -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)
|
sanic_security/test/server.py
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
import datetime
|
2
2
|
import traceback
|
3
3
|
|
4
|
-
from
|
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
|
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.
|
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("
|
56
|
-
|
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")
|
68
|
-
disabled=request.form.get("disabled")
|
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,
|
98
|
+
request,
|
99
|
+
require_second_factor=str_to_bool(
|
100
|
+
request.args.get("two-factor-authentication")
|
101
|
+
),
|
96
102
|
)
|
97
|
-
if
|
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
|
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")
|
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
|
-
|
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
|
-
|
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
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
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=
|
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)
|
sanic_security/test/tests.py
CHANGED
@@ -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,
|
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,
|
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": "
|
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
|
102
|
-
|
103
|
-
)
|
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 (
|
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
|
|