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

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,238 @@
1
+ import functools
2
+ import time
3
+ from contextlib import suppress
4
+
5
+ import jwt
6
+ from httpx_oauth.exceptions import GetIdEmailError
7
+ from httpx_oauth.oauth2 import (
8
+ BaseOAuth2,
9
+ RefreshTokenError,
10
+ RevokeTokenError,
11
+ RevokeTokenNotSupportedError,
12
+ GetAccessTokenError,
13
+ )
14
+ from jwt import DecodeError
15
+ from sanic import Request, HTTPResponse, Sanic
16
+ from sanic.log import logger
17
+ from tortoise.exceptions import IntegrityError, DoesNotExist
18
+
19
+ from sanic_security.configuration import config
20
+ from sanic_security.exceptions import (
21
+ CredentialsError,
22
+ OAuthError,
23
+ )
24
+ from sanic_security.models import Account, AuthenticationSession
25
+ from sanic_security.utils import get_ip
26
+
27
+ """
28
+ Copyright (c) 2020-present Nicholas Aidan Stewart
29
+
30
+ Permission is hereby granted, free of charge, to any person obtaining a copy
31
+ of this software and associated documentation files (the "Software"), to deal
32
+ in the Software without restriction, including without limitation the rights
33
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
34
+ copies of the Software, and to permit persons to whom the Software is
35
+ furnished to do so, subject to the following conditions:
36
+
37
+ The above copyright notice and this permission notice shall be included in all
38
+ copies or substantial portions of the Software.
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
42
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
43
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
44
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
45
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
46
+ SOFTWARE.
47
+ """
48
+
49
+
50
+ async def oauth_callback(
51
+ request: Request,
52
+ client: BaseOAuth2,
53
+ redirect_uri: str = config.OAUTH_REDIRECT,
54
+ code_verifier: str = None,
55
+ ) -> tuple[dict, AuthenticationSession]:
56
+ """
57
+ Requests an access token using the authorization code obtained after the user has authorized the application.
58
+ An account is retrieved if it already exists, created if it doesn't, and the user is logged in.
59
+
60
+ Args:
61
+ request (Request): Sanic request parameter.
62
+ client (BaseOAuth2): OAuth provider.
63
+ redirect_uri (str): The URL where the user was redirected after authorization.
64
+ code_verifier (str): Optional code verifier used in the [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) flow.
65
+
66
+ Raises:
67
+ CredentialsError
68
+ OAuthError
69
+
70
+ Returns:
71
+ token_info, authentication_session
72
+ """
73
+ try:
74
+ token_info = await client.get_access_token(
75
+ request.args.get("code"),
76
+ redirect_uri,
77
+ code_verifier,
78
+ )
79
+ if "expires_at" not in token_info:
80
+ token_info["expires_at"] = time.time() + token_info["expires_in"]
81
+ oauth_id, email = await client.get_id_email(token_info["access_token"])
82
+ try:
83
+ account = await Account.get(oauth_id=oauth_id)
84
+ except DoesNotExist:
85
+ account = await Account.create(
86
+ email=email,
87
+ username=email.split("@")[0],
88
+ password="",
89
+ oauth_id=oauth_id,
90
+ verified=True,
91
+ )
92
+ authentication_session = await AuthenticationSession.new(
93
+ request,
94
+ account,
95
+ )
96
+ logger.info(
97
+ f"Client {get_ip(request)} has logged in via {client.__class__.__name__} with authentication session {authentication_session.id}."
98
+ )
99
+ return token_info, authentication_session
100
+ except IntegrityError:
101
+ raise CredentialsError(
102
+ f"Account may not be linked to this OAuth provider if it already exists.",
103
+ 409,
104
+ )
105
+ except GetAccessTokenError as e:
106
+ raise OAuthError(f"Failed to retrieve access token: {e.response.text}")
107
+ except GetIdEmailError as e:
108
+ raise OAuthError(f"Failed to retrieve id and email: {e.response.text}")
109
+
110
+
111
+ def oauth_encode(response: HTTPResponse, token_info: dict) -> None:
112
+ """
113
+ Transforms access token into JWT and then is stored in a cookie.
114
+
115
+ Args:
116
+ response (HTTPResponse): Sanic response used to store JWT into a cookie on the client.
117
+ token_info (dict): OAuth access token.
118
+ """
119
+ response.cookies.add_cookie(
120
+ f"{config.SESSION_PREFIX}_oauth",
121
+ str(
122
+ jwt.encode(
123
+ token_info,
124
+ config.SECRET,
125
+ config.SESSION_ENCODING_ALGORITHM,
126
+ ),
127
+ ),
128
+ httponly=config.SESSION_HTTPONLY,
129
+ samesite=config.SESSION_SAMESITE,
130
+ secure=config.SESSION_SECURE,
131
+ domain=config.SESSION_DOMAIN,
132
+ max_age=token_info["expires_in"] + config.AUTHENTICATION_REFRESH_EXPIRATION,
133
+ )
134
+
135
+
136
+ async def oauth_revoke(request: Request, client: BaseOAuth2) -> dict:
137
+ """
138
+ Revokes the client's access token.
139
+
140
+ Args:
141
+ request (Request): Sanic request parameter.
142
+ client (BaseOAuth2): OAuth provider.
143
+
144
+ Raises:
145
+ OAuthError
146
+ """
147
+ if request.cookies.get(f"{config.SESSION_PREFIX}_oauth"):
148
+ try:
149
+ token_info = await oauth_decode(request, client, False)
150
+ request.ctx.oauth["revoked"] = True
151
+ with suppress(RevokeTokenNotSupportedError):
152
+ await client.revoke_token(token_info.get("access_token"))
153
+ return token_info
154
+ except RevokeTokenError as e:
155
+ raise OAuthError(f"Failed to revoke access token {e.response.text}")
156
+
157
+
158
+ async def oauth_decode(request: Request, client: BaseOAuth2, refresh=True) -> dict:
159
+ """
160
+ Decodes JWT token from client cookie into an access token.
161
+
162
+ Args:
163
+ request (Request): Sanic request parameter.
164
+ client (BaseOAuth2): OAuth provider.
165
+ refresh (bool): Determines that the decoded access token is refreshed during expiration.
166
+
167
+ Raises:
168
+ OAuthError
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 refresh and time.time() > token_info["expires_at"]:
182
+ token_info = await client.refresh_token(token_info.get("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 as e:
189
+ raise OAuthError(f"Failed to refresh access token: {e.response.text}")
190
+ except DecodeError:
191
+ raise OAuthError(f"Access token invalid, not provided, or expired.", 400)
192
+
193
+
194
+ def requires_oauth(client: BaseOAuth2):
195
+ """
196
+ Decodes 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.get('api/oauth')
205
+ @requires_oauth
206
+ async def on_oauth(request):
207
+ return text('Access token retrieved!')
208
+
209
+ Raises:
210
+ OAuthError
211
+ """
212
+
213
+ def decorator(func):
214
+ @functools.wraps(func)
215
+ async def wrapper(request, *args, **kwargs):
216
+ await oauth_decode(request, client)
217
+ return await func(request, *args, **kwargs)
218
+
219
+ return wrapper
220
+
221
+ return decorator
222
+
223
+
224
+ def initialize_oauth(app: Sanic) -> None:
225
+ """
226
+ Attaches session middleware.
227
+
228
+ Args:
229
+ app (Sanic): Sanic application instance.
230
+ """
231
+
232
+ @app.on_response
233
+ async def session_middleware(request, response):
234
+ if hasattr(request.ctx, "oauth"):
235
+ if request.ctx.oauth.get("is_refresh"):
236
+ oauth_encode(response, request.ctx.oauth)
237
+ elif request.ctx.oauth.get("revoked"):
238
+ response.delete_cookie(f"{config.SESSION_PREFIX}_oauth")