fps_auth 0.9.5__tar.gz
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.
- fps_auth-0.9.5/COPYING.md +59 -0
- fps_auth-0.9.5/PKG-INFO +30 -0
- fps_auth-0.9.5/README.md +3 -0
- fps_auth-0.9.5/pyproject.toml +45 -0
- fps_auth-0.9.5/src/fps_auth/__init__.py +6 -0
- fps_auth-0.9.5/src/fps_auth/backends.py +292 -0
- fps_auth-0.9.5/src/fps_auth/config.py +18 -0
- fps_auth-0.9.5/src/fps_auth/db.py +102 -0
- fps_auth-0.9.5/src/fps_auth/main.py +59 -0
- fps_auth-0.9.5/src/fps_auth/models.py +21 -0
- fps_auth-0.9.5/src/fps_auth/py.typed +0 -0
- fps_auth-0.9.5/src/fps_auth/routes.py +255 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Licensing terms
|
|
2
|
+
|
|
3
|
+
This project is licensed under the terms of the Modified BSD License
|
|
4
|
+
(also known as New or Revised or 3-Clause BSD), as follows:
|
|
5
|
+
|
|
6
|
+
- Copyright (c) 2021-, Jupyter Development Team
|
|
7
|
+
|
|
8
|
+
All rights reserved.
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
Redistributions of source code must retain the above copyright notice, this
|
|
14
|
+
list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
Redistributions in binary form must reproduce the above copyright notice, this
|
|
17
|
+
list of conditions and the following disclaimer in the documentation and/or
|
|
18
|
+
other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
Neither the name of the Jupyter Development Team nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from this
|
|
22
|
+
software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
25
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
26
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
## About the Jupyter Development Team
|
|
36
|
+
|
|
37
|
+
The Jupyter Development Team is the set of all contributors to the Jupyter project.
|
|
38
|
+
This includes all of the Jupyter subprojects.
|
|
39
|
+
|
|
40
|
+
The core team that coordinates development on GitHub can be found here:
|
|
41
|
+
https://github.com/jupyter/.
|
|
42
|
+
|
|
43
|
+
## Our Copyright Policy
|
|
44
|
+
|
|
45
|
+
Jupyter uses a shared copyright model. Each contributor maintains copyright
|
|
46
|
+
over their contributions to Jupyter. But, it is important to note that these
|
|
47
|
+
contributions are typically only changes to the repositories. Thus, the Jupyter
|
|
48
|
+
source code, in its entirety is not the copyright of any single person or
|
|
49
|
+
institution. Instead, it is the collective copyright of the entire Jupyter
|
|
50
|
+
Development Team. If individual contributors want to maintain a record of what
|
|
51
|
+
changes/contributions they have specific copyright on, they should indicate
|
|
52
|
+
their copyright in the commit message of the change, when they commit the
|
|
53
|
+
change to one of the Jupyter repositories.
|
|
54
|
+
|
|
55
|
+
With this in mind, the following banner should be used in any source code file
|
|
56
|
+
to indicate the copyright and license terms:
|
|
57
|
+
|
|
58
|
+
# Copyright (c) Jupyter Development Team.
|
|
59
|
+
# Distributed under the terms of the Modified BSD License.
|
fps_auth-0.9.5/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fps_auth
|
|
3
|
+
Version: 0.9.5
|
|
4
|
+
Summary: An FPS plugin for the authentication API
|
|
5
|
+
Keywords: jupyter,server,fastapi,plugins
|
|
6
|
+
Author: Jupyter Development Team
|
|
7
|
+
Author-email: Jupyter Development Team <jupyter@googlegroups.com>
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
License-File: COPYING.md
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
21
|
+
Requires-Dist: aiosqlite
|
|
22
|
+
Requires-Dist: fastapi-users[sqlalchemy,oauth]>=14.0.1,<15.0.0
|
|
23
|
+
Requires-Dist: jupyverse-api>=0.12.0,<0.13.0
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Homepage, https://jupyter.org
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# fps-auth
|
|
29
|
+
|
|
30
|
+
An FPS plugin for the authentication API.
|
fps_auth-0.9.5/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fps_auth"
|
|
7
|
+
version = "0.9.5"
|
|
8
|
+
description = "An FPS plugin for the authentication API"
|
|
9
|
+
keywords = ["jupyter", "server", "fastapi", "plugins"]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 5 - Production/Stable",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: BSD License",
|
|
15
|
+
"Programming Language :: Python",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
22
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"aiosqlite",
|
|
26
|
+
"fastapi-users[sqlalchemy,oauth] >=14.0.1,<15.0.0",
|
|
27
|
+
"jupyverse-api >=0.12.0,<0.13.0",
|
|
28
|
+
]
|
|
29
|
+
license = "BSD-3-Clause"
|
|
30
|
+
license-files = ["COPYING.md"]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Jupyter Development Team"
|
|
34
|
+
email = "jupyter@googlegroups.com"
|
|
35
|
+
|
|
36
|
+
[project.readme]
|
|
37
|
+
file = "README.md"
|
|
38
|
+
content-type = "text/markdown"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://jupyter.org"
|
|
42
|
+
|
|
43
|
+
[project.entry-points]
|
|
44
|
+
"fps.modules" = {auth = "fps_auth.main:AuthModule"}
|
|
45
|
+
"jupyverse.modules" = {auth = "fps_auth.main:AuthModule"}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Generic, cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import Depends, HTTPException, Response, WebSocket, status
|
|
7
|
+
from fastapi_users import (
|
|
8
|
+
BaseUserManager,
|
|
9
|
+
FastAPIUsers,
|
|
10
|
+
UUIDIDMixin,
|
|
11
|
+
models,
|
|
12
|
+
)
|
|
13
|
+
from fastapi_users.authentication import (
|
|
14
|
+
AuthenticationBackend,
|
|
15
|
+
CookieTransport,
|
|
16
|
+
JWTStrategy,
|
|
17
|
+
)
|
|
18
|
+
from fastapi_users.authentication.strategy.base import Strategy
|
|
19
|
+
from fastapi_users.authentication.transport.base import Transport
|
|
20
|
+
from fastapi_users.db import SQLAlchemyUserDatabase
|
|
21
|
+
from httpx_oauth.clients.github import GitHubOAuth2
|
|
22
|
+
from jupyverse_api.exceptions import RedirectException
|
|
23
|
+
from jupyverse_api.frontend import FrontendConfig
|
|
24
|
+
from starlette.requests import Request
|
|
25
|
+
|
|
26
|
+
from .config import _AuthConfig
|
|
27
|
+
from .db import User
|
|
28
|
+
from .models import UserCreate, UserRead
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Res:
|
|
33
|
+
cookie_authentication: Any
|
|
34
|
+
current_user: Any
|
|
35
|
+
update_user: Any
|
|
36
|
+
fapi_users: Any
|
|
37
|
+
get_user_manager: Any
|
|
38
|
+
github_authentication: Any
|
|
39
|
+
github_cookie_authentication: Any
|
|
40
|
+
websocket_auth: Any
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_backend(auth_config: _AuthConfig, frontend_config: FrontendConfig, db) -> Res:
|
|
44
|
+
class NoAuthScheme:
|
|
45
|
+
def __call__(self):
|
|
46
|
+
return "noauth"
|
|
47
|
+
|
|
48
|
+
class NoAuthTransport(Transport):
|
|
49
|
+
scheme = NoAuthScheme() # type: ignore
|
|
50
|
+
|
|
51
|
+
async def get_login_response(self, token: str) -> Response:
|
|
52
|
+
return Response()
|
|
53
|
+
|
|
54
|
+
async def get_logout_response(self) -> Response:
|
|
55
|
+
return Response()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def get_openapi_login_responses_success():
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def get_openapi_logout_responses_success():
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
class NoAuthStrategy(Strategy, Generic[models.UP, models.ID]):
|
|
66
|
+
async def read_token(
|
|
67
|
+
self, token: str | None, user_manager: BaseUserManager[models.UP, models.ID]
|
|
68
|
+
) -> models.UP | None:
|
|
69
|
+
active_user = await user_manager.user_db.get_by_email(auth_config.global_email)
|
|
70
|
+
return active_user
|
|
71
|
+
|
|
72
|
+
async def write_token(self, user: models.UP):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
async def destroy_token(self, token: str, user: models.UP):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
class GitHubTransport(CookieTransport):
|
|
79
|
+
async def get_login_response(self, token: str) -> Response:
|
|
80
|
+
response = await super().get_login_response(token)
|
|
81
|
+
response.status_code = status.HTTP_302_FOUND
|
|
82
|
+
response.headers["Location"] = "/lab"
|
|
83
|
+
return response
|
|
84
|
+
|
|
85
|
+
def get_noauth_strategy() -> NoAuthStrategy:
|
|
86
|
+
return NoAuthStrategy()
|
|
87
|
+
|
|
88
|
+
def get_jwt_strategy() -> JWTStrategy:
|
|
89
|
+
return JWTStrategy(secret=db.secret, lifetime_seconds=None)
|
|
90
|
+
|
|
91
|
+
noauth_authentication = AuthenticationBackend(
|
|
92
|
+
name="noauth",
|
|
93
|
+
transport=NoAuthTransport(),
|
|
94
|
+
get_strategy=get_noauth_strategy,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
cookie_authentication = AuthenticationBackend(
|
|
98
|
+
name="cookie",
|
|
99
|
+
transport=CookieTransport(cookie_secure=auth_config.cookie_secure),
|
|
100
|
+
get_strategy=get_jwt_strategy,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
github_cookie_authentication = AuthenticationBackend(
|
|
104
|
+
name="github",
|
|
105
|
+
transport=GitHubTransport(),
|
|
106
|
+
get_strategy=get_jwt_strategy,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
github_authentication = GitHubOAuth2(auth_config.client_id, auth_config.client_secret)
|
|
110
|
+
|
|
111
|
+
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|
112
|
+
async def on_after_register(self, user: User, request: Request | None = None):
|
|
113
|
+
for oauth_account in await user.awaitable_attrs.oauth_accounts:
|
|
114
|
+
if oauth_account.oauth_name == "github":
|
|
115
|
+
async with httpx.AsyncClient() as client:
|
|
116
|
+
r = (
|
|
117
|
+
await client.get(
|
|
118
|
+
f"https://api.github.com/user/{oauth_account.account_id}"
|
|
119
|
+
)
|
|
120
|
+
).json()
|
|
121
|
+
|
|
122
|
+
await self.user_db.update(
|
|
123
|
+
user,
|
|
124
|
+
dict(
|
|
125
|
+
anonymous=False,
|
|
126
|
+
username=r["login"],
|
|
127
|
+
color=None,
|
|
128
|
+
avatar_url=r["avatar_url"],
|
|
129
|
+
is_active=True,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(db.get_user_db)):
|
|
134
|
+
yield UserManager(user_db)
|
|
135
|
+
|
|
136
|
+
def get_enabled_backends():
|
|
137
|
+
if auth_config.mode == "noauth" and not frontend_config.collaborative:
|
|
138
|
+
res = [noauth_authentication, github_cookie_authentication]
|
|
139
|
+
else:
|
|
140
|
+
res = [cookie_authentication, github_cookie_authentication]
|
|
141
|
+
return res
|
|
142
|
+
|
|
143
|
+
fapi_users = FastAPIUsers[User, uuid.UUID](
|
|
144
|
+
get_user_manager,
|
|
145
|
+
[
|
|
146
|
+
noauth_authentication,
|
|
147
|
+
cookie_authentication,
|
|
148
|
+
github_cookie_authentication,
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def create_guest(user_manager):
|
|
153
|
+
# workspace and settings are copied from global user
|
|
154
|
+
# but this is a new user
|
|
155
|
+
global_user = await user_manager.get_by_email(auth_config.global_email)
|
|
156
|
+
user_id = str(uuid.uuid4())
|
|
157
|
+
guest = dict(
|
|
158
|
+
anonymous=True,
|
|
159
|
+
email=f"{user_id}@jupyter.com",
|
|
160
|
+
username=f"{user_id}@jupyter.com",
|
|
161
|
+
password="",
|
|
162
|
+
workspace=global_user.workspace,
|
|
163
|
+
settings=global_user.settings,
|
|
164
|
+
permissions={},
|
|
165
|
+
)
|
|
166
|
+
return await user_manager.create(UserCreate(**guest))
|
|
167
|
+
|
|
168
|
+
def current_user(permissions: dict[str, list[str]] | None = None):
|
|
169
|
+
async def _(
|
|
170
|
+
response: Response,
|
|
171
|
+
token: str | None = None,
|
|
172
|
+
user: User | None = Depends(
|
|
173
|
+
fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends)
|
|
174
|
+
),
|
|
175
|
+
user_manager: BaseUserManager[User, models.ID] = Depends(get_user_manager),
|
|
176
|
+
):
|
|
177
|
+
if auth_config.mode == "user":
|
|
178
|
+
# "user" authentication: check authorization
|
|
179
|
+
if user and permissions:
|
|
180
|
+
for resource, actions in permissions.items():
|
|
181
|
+
user_actions_for_resource = user.permissions.get(resource, [])
|
|
182
|
+
if not all([a in user_actions_for_resource for a in actions]):
|
|
183
|
+
user = None
|
|
184
|
+
break
|
|
185
|
+
else:
|
|
186
|
+
# "noauth" or "token" authentication
|
|
187
|
+
if frontend_config.collaborative:
|
|
188
|
+
if not user and auth_config.mode == "noauth":
|
|
189
|
+
user = await create_guest(user_manager)
|
|
190
|
+
token = await get_jwt_strategy().write_token(user)
|
|
191
|
+
cookie_transport = cast(CookieTransport, cookie_authentication.transport)
|
|
192
|
+
cookie_transport._set_login_cookie(response, token)
|
|
193
|
+
|
|
194
|
+
elif not user and auth_config.mode == "token":
|
|
195
|
+
global_user = await user_manager.get_by_email(auth_config.global_email)
|
|
196
|
+
if global_user and global_user.username == token:
|
|
197
|
+
user = await create_guest(user_manager)
|
|
198
|
+
token = await get_jwt_strategy().write_token(user)
|
|
199
|
+
cookie_transport = cast(
|
|
200
|
+
CookieTransport, cookie_authentication.transport
|
|
201
|
+
)
|
|
202
|
+
cookie_transport._set_login_cookie(response, token)
|
|
203
|
+
else:
|
|
204
|
+
if auth_config.mode == "token":
|
|
205
|
+
global_user = await user_manager.get_by_email(auth_config.global_email)
|
|
206
|
+
if global_user and global_user.username == token:
|
|
207
|
+
user = global_user
|
|
208
|
+
token = await get_jwt_strategy().write_token(user)
|
|
209
|
+
cookie_transport = cast(
|
|
210
|
+
CookieTransport, cookie_authentication.transport
|
|
211
|
+
)
|
|
212
|
+
cookie_transport._set_login_cookie(response, token)
|
|
213
|
+
|
|
214
|
+
if user:
|
|
215
|
+
return user
|
|
216
|
+
|
|
217
|
+
elif auth_config.login_url:
|
|
218
|
+
raise RedirectException(auth_config.login_url)
|
|
219
|
+
|
|
220
|
+
else:
|
|
221
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
222
|
+
|
|
223
|
+
return _
|
|
224
|
+
|
|
225
|
+
def websocket_auth(permissions: dict[str, list[str]] | None = None):
|
|
226
|
+
"""
|
|
227
|
+
A function returning a dependency for the WebSocket connection.
|
|
228
|
+
|
|
229
|
+
:param permissions: the permissions the user should be granted access to. The user should
|
|
230
|
+
have access to at least one of them for the WebSocket to be opened.
|
|
231
|
+
:returns: a dependency for the WebSocket connection. The dependency returns a tuple
|
|
232
|
+
consisting of the websocket and the checked user permissions if the websocket is accepted,
|
|
233
|
+
None otherwise.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
async def _(
|
|
237
|
+
websocket: WebSocket,
|
|
238
|
+
user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager),
|
|
239
|
+
) -> tuple[WebSocket, dict[str, list[str]] | None] | None:
|
|
240
|
+
accept_websocket = False
|
|
241
|
+
checked_permissions: dict[str, list[str]] | None = None
|
|
242
|
+
if auth_config.mode == "noauth":
|
|
243
|
+
accept_websocket = True
|
|
244
|
+
elif "fastapiusersauth" in websocket._cookies:
|
|
245
|
+
token = websocket._cookies["fastapiusersauth"]
|
|
246
|
+
user = await get_jwt_strategy().read_token(token, user_manager)
|
|
247
|
+
if user:
|
|
248
|
+
if auth_config.mode == "user":
|
|
249
|
+
# "user" authentication: check authorization
|
|
250
|
+
if permissions is None:
|
|
251
|
+
accept_websocket = True
|
|
252
|
+
else:
|
|
253
|
+
checked_permissions = {}
|
|
254
|
+
for resource, actions in permissions.items():
|
|
255
|
+
user_actions_for_resource = user.permissions.get(resource)
|
|
256
|
+
if user_actions_for_resource is None:
|
|
257
|
+
continue
|
|
258
|
+
allowed = checked_permissions[resource] = []
|
|
259
|
+
for action in actions:
|
|
260
|
+
if action in user_actions_for_resource:
|
|
261
|
+
allowed.append(action)
|
|
262
|
+
accept_websocket = True
|
|
263
|
+
else:
|
|
264
|
+
accept_websocket = True
|
|
265
|
+
if accept_websocket:
|
|
266
|
+
return websocket, checked_permissions
|
|
267
|
+
else:
|
|
268
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
return _
|
|
272
|
+
|
|
273
|
+
async def update_user(
|
|
274
|
+
user: UserRead = Depends(current_user()),
|
|
275
|
+
user_db: SQLAlchemyUserDatabase = Depends(db.get_user_db),
|
|
276
|
+
):
|
|
277
|
+
async def _(data: dict[str, Any]) -> UserRead:
|
|
278
|
+
await user_db.update(user, data)
|
|
279
|
+
return user
|
|
280
|
+
|
|
281
|
+
return _
|
|
282
|
+
|
|
283
|
+
return Res(
|
|
284
|
+
cookie_authentication=cookie_authentication,
|
|
285
|
+
current_user=current_user,
|
|
286
|
+
update_user=update_user,
|
|
287
|
+
fapi_users=fapi_users,
|
|
288
|
+
get_user_manager=get_user_manager,
|
|
289
|
+
github_authentication=github_authentication,
|
|
290
|
+
github_cookie_authentication=github_cookie_authentication,
|
|
291
|
+
websocket_auth=websocket_auth,
|
|
292
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
|
|
3
|
+
from jupyverse_api.auth import AuthConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _AuthConfig(AuthConfig):
|
|
7
|
+
client_id: str = ""
|
|
8
|
+
client_secret: str = ""
|
|
9
|
+
redirect_uri: str = ""
|
|
10
|
+
# mode: Literal["noauth", "token", "user"] = "token"
|
|
11
|
+
mode: str = "token"
|
|
12
|
+
token: str = uuid4().hex
|
|
13
|
+
global_email: str = "guest@jupyter.com"
|
|
14
|
+
cookie_secure: bool = False # FIXME: should default to True, and set to False for tests
|
|
15
|
+
clear_users: bool = False
|
|
16
|
+
test: bool = False
|
|
17
|
+
login_url: str | None = None
|
|
18
|
+
directory: str | None = None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from fastapi_users.db import (
|
|
9
|
+
SQLAlchemyBaseOAuthAccountTableUUID,
|
|
10
|
+
SQLAlchemyBaseUserTableUUID,
|
|
11
|
+
SQLAlchemyUserDatabase,
|
|
12
|
+
)
|
|
13
|
+
from sqlalchemy import JSON, Boolean, Column, String, Text
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine
|
|
15
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, relationship
|
|
16
|
+
|
|
17
|
+
from .config import _AuthConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Base(AsyncAttrs, DeclarativeBase):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class User(SQLAlchemyBaseUserTableUUID, Base):
|
|
29
|
+
anonymous = Column(Boolean, default=True, nullable=False)
|
|
30
|
+
username = Column(String(length=32), nullable=False, unique=True)
|
|
31
|
+
name = Column(String(length=32), default="")
|
|
32
|
+
display_name = Column(String(length=32), default="")
|
|
33
|
+
initials = Column(String(length=8), nullable=True)
|
|
34
|
+
color = Column(String(length=32), nullable=True)
|
|
35
|
+
avatar_url = Column(String(length=32), nullable=True)
|
|
36
|
+
workspace = Column(Text(), default="{}", nullable=False)
|
|
37
|
+
settings = Column(Text(), default="{}", nullable=False)
|
|
38
|
+
permissions = Column(JSON, default={}, nullable=False)
|
|
39
|
+
oauth_accounts: Mapped[list[OAuthAccount]] = relationship("OAuthAccount", lazy="joined")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Res:
|
|
44
|
+
User: Any
|
|
45
|
+
async_session_maker: Any
|
|
46
|
+
create_db_and_tables: Any
|
|
47
|
+
get_async_session: Any
|
|
48
|
+
get_user_db: Any
|
|
49
|
+
secret: Any
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_db(auth_config: _AuthConfig) -> Res:
|
|
53
|
+
jupyter_dir = (
|
|
54
|
+
Path.home() / ".local" / "share" / "jupyter"
|
|
55
|
+
if auth_config.directory is None
|
|
56
|
+
else Path(auth_config.directory)
|
|
57
|
+
)
|
|
58
|
+
jupyter_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
name = "jupyverse"
|
|
60
|
+
if auth_config.test:
|
|
61
|
+
name += "_test"
|
|
62
|
+
secret_path = jupyter_dir / f"{name}_secret"
|
|
63
|
+
userdb_path = jupyter_dir / f"{name}_users.db"
|
|
64
|
+
|
|
65
|
+
if auth_config.clear_users:
|
|
66
|
+
if userdb_path.is_file():
|
|
67
|
+
userdb_path.unlink()
|
|
68
|
+
if secret_path.is_file():
|
|
69
|
+
secret_path.unlink()
|
|
70
|
+
if auth_config.mode == "token":
|
|
71
|
+
if secret_path.is_file():
|
|
72
|
+
secret_path.unlink()
|
|
73
|
+
|
|
74
|
+
if not secret_path.is_file():
|
|
75
|
+
secret_path.write_text(secrets.token_hex(32))
|
|
76
|
+
|
|
77
|
+
secret = secret_path.read_text()
|
|
78
|
+
|
|
79
|
+
database_url = f"sqlite+aiosqlite:///{userdb_path}"
|
|
80
|
+
|
|
81
|
+
engine = create_async_engine(database_url)
|
|
82
|
+
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
83
|
+
|
|
84
|
+
async def create_db_and_tables():
|
|
85
|
+
async with engine.begin() as conn:
|
|
86
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
87
|
+
|
|
88
|
+
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
|
89
|
+
async with async_session_maker() as session:
|
|
90
|
+
yield session
|
|
91
|
+
|
|
92
|
+
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
|
|
93
|
+
yield SQLAlchemyUserDatabase(session, User, OAuthAccount)
|
|
94
|
+
|
|
95
|
+
return Res(
|
|
96
|
+
User=User,
|
|
97
|
+
async_session_maker=async_session_maker,
|
|
98
|
+
create_db_and_tables=create_db_and_tables,
|
|
99
|
+
get_async_session=get_async_session,
|
|
100
|
+
get_user_db=get_user_db,
|
|
101
|
+
secret=secret,
|
|
102
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import structlog
|
|
2
|
+
from fastapi_users.exceptions import UserAlreadyExists
|
|
3
|
+
from fps import Module
|
|
4
|
+
from jupyverse_api.app import App
|
|
5
|
+
from jupyverse_api.auth import Auth, AuthConfig
|
|
6
|
+
from jupyverse_api.frontend import FrontendConfig
|
|
7
|
+
from jupyverse_api.main import QueryParams
|
|
8
|
+
|
|
9
|
+
from .config import _AuthConfig
|
|
10
|
+
from .routes import auth_factory
|
|
11
|
+
|
|
12
|
+
log = structlog.get_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthModule(Module):
|
|
16
|
+
def __init__(self, name: str, **kwargs):
|
|
17
|
+
super().__init__(name)
|
|
18
|
+
self.config = _AuthConfig(**kwargs)
|
|
19
|
+
|
|
20
|
+
async def prepare(self) -> None:
|
|
21
|
+
self.put(self.config, AuthConfig)
|
|
22
|
+
|
|
23
|
+
app = await self.get(App)
|
|
24
|
+
frontend_config = await self.get(FrontendConfig)
|
|
25
|
+
|
|
26
|
+
auth = auth_factory(app, self.config, frontend_config)
|
|
27
|
+
self.put(auth, Auth)
|
|
28
|
+
|
|
29
|
+
await auth.db.create_db_and_tables()
|
|
30
|
+
|
|
31
|
+
if self.config.test:
|
|
32
|
+
try:
|
|
33
|
+
await auth.create_user(
|
|
34
|
+
username="admin@jupyter.com",
|
|
35
|
+
email="admin@jupyter.com",
|
|
36
|
+
password="jupyverse",
|
|
37
|
+
permissions={"admin": ["read", "write"]},
|
|
38
|
+
)
|
|
39
|
+
except UserAlreadyExists:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
await auth.create_user(
|
|
44
|
+
username=self.config.token,
|
|
45
|
+
email=self.config.global_email,
|
|
46
|
+
password="",
|
|
47
|
+
permissions={},
|
|
48
|
+
)
|
|
49
|
+
except UserAlreadyExists:
|
|
50
|
+
global_user = await auth.get_user_by_email(self.config.global_email)
|
|
51
|
+
await auth._update_user(
|
|
52
|
+
global_user,
|
|
53
|
+
username=self.config.token,
|
|
54
|
+
permissions={},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if self.config.mode == "token":
|
|
58
|
+
query_params = await self.get(QueryParams)
|
|
59
|
+
query_params.d["token"] = self.config.token
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from fastapi_users import schemas
|
|
4
|
+
from jupyverse_api.auth import User
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JupyterUser(User):
|
|
8
|
+
anonymous: bool = True
|
|
9
|
+
permissions: dict[str, list[str]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserRead(schemas.BaseUser[uuid.UUID], JupyterUser):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserCreate(schemas.BaseUserCreate, JupyterUser):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserUpdate(schemas.BaseUserUpdate, JupyterUser):
|
|
21
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends
|
|
8
|
+
from jupyverse_api.app import App
|
|
9
|
+
from jupyverse_api.auth import Auth
|
|
10
|
+
from jupyverse_api.frontend import FrontendConfig
|
|
11
|
+
from sqlalchemy import select # type: ignore
|
|
12
|
+
|
|
13
|
+
from jupyverse_api import Router
|
|
14
|
+
|
|
15
|
+
from .backends import get_backend
|
|
16
|
+
from .config import _AuthConfig
|
|
17
|
+
from .db import get_db
|
|
18
|
+
from .models import UserCreate, UserRead, UserUpdate
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def auth_factory(
|
|
22
|
+
app: App,
|
|
23
|
+
auth_config: _AuthConfig,
|
|
24
|
+
frontend_config: FrontendConfig,
|
|
25
|
+
):
|
|
26
|
+
db = get_db(auth_config)
|
|
27
|
+
backend = get_backend(auth_config, frontend_config, db)
|
|
28
|
+
|
|
29
|
+
get_async_session_context = contextlib.asynccontextmanager(db.get_async_session)
|
|
30
|
+
get_user_db_context = contextlib.asynccontextmanager(db.get_user_db)
|
|
31
|
+
get_user_manager_context = contextlib.asynccontextmanager(backend.get_user_manager)
|
|
32
|
+
|
|
33
|
+
@contextlib.asynccontextmanager
|
|
34
|
+
async def _get_user_manager():
|
|
35
|
+
async with get_async_session_context() as session:
|
|
36
|
+
async with get_user_db_context(session) as user_db:
|
|
37
|
+
async with get_user_manager_context(user_db) as user_manager:
|
|
38
|
+
yield user_manager
|
|
39
|
+
|
|
40
|
+
async def create_user(**kwargs):
|
|
41
|
+
async with _get_user_manager() as user_manager:
|
|
42
|
+
await user_manager.create(UserCreate(**kwargs))
|
|
43
|
+
|
|
44
|
+
async def update_user(user, **kwargs):
|
|
45
|
+
async with _get_user_manager() as user_manager:
|
|
46
|
+
await user_manager.update(UserUpdate(**kwargs), user)
|
|
47
|
+
|
|
48
|
+
async def get_user_by_email(user_email):
|
|
49
|
+
async with _get_user_manager() as user_manager:
|
|
50
|
+
return await user_manager.get_by_email(user_email)
|
|
51
|
+
|
|
52
|
+
class _Auth(Auth, Router):
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
super().__init__(app)
|
|
55
|
+
|
|
56
|
+
self.db = db
|
|
57
|
+
|
|
58
|
+
router = APIRouter()
|
|
59
|
+
|
|
60
|
+
@router.get("/auth/users")
|
|
61
|
+
async def get_users(
|
|
62
|
+
user: UserRead = Depends(backend.current_user(permissions={"admin": ["read"]})),
|
|
63
|
+
):
|
|
64
|
+
async with db.async_session_maker() as session:
|
|
65
|
+
statement = select(db.User)
|
|
66
|
+
users = (await session.execute(statement)).unique().all()
|
|
67
|
+
return [usr.User for usr in users if usr.User.is_active]
|
|
68
|
+
|
|
69
|
+
@router.get("/api/me")
|
|
70
|
+
async def get_api_me(
|
|
71
|
+
permissions: str | None = None,
|
|
72
|
+
user: UserRead = Depends(backend.current_user()),
|
|
73
|
+
update_user=Depends(backend.update_user),
|
|
74
|
+
):
|
|
75
|
+
checked_permissions: dict[str, list[str]] = {}
|
|
76
|
+
if permissions is None:
|
|
77
|
+
permissions = "{}"
|
|
78
|
+
else:
|
|
79
|
+
permissions = permissions.replace("'", '"')
|
|
80
|
+
permissions_dict = json.loads(permissions)
|
|
81
|
+
if permissions_dict:
|
|
82
|
+
user_permissions = user.permissions
|
|
83
|
+
for resource, actions in permissions_dict.items():
|
|
84
|
+
user_resource_permissions = user_permissions.get(resource)
|
|
85
|
+
if user_resource_permissions is None:
|
|
86
|
+
continue
|
|
87
|
+
allowed = checked_permissions[resource] = []
|
|
88
|
+
for action in actions:
|
|
89
|
+
if action in user_resource_permissions:
|
|
90
|
+
allowed.append(action)
|
|
91
|
+
|
|
92
|
+
keys = ["username", "name", "display_name", "initials", "avatar_url", "color"]
|
|
93
|
+
identity = {k: getattr(user, k) for k in keys}
|
|
94
|
+
if not identity["name"] and not identity["display_name"]:
|
|
95
|
+
moon = get_anonymous_username()
|
|
96
|
+
identity["name"] = f"Anonymous {moon}"
|
|
97
|
+
identity["display_name"] = f"Anonymous {moon}"
|
|
98
|
+
identity["initials"] = f"A{moon[0]}"
|
|
99
|
+
await update_user(
|
|
100
|
+
dict(
|
|
101
|
+
name=identity["name"],
|
|
102
|
+
display_name=identity["display_name"],
|
|
103
|
+
permissions=checked_permissions,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return {
|
|
107
|
+
"identity": identity,
|
|
108
|
+
"permissions": checked_permissions,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# redefine GET /me because we want our current_user dependency
|
|
112
|
+
# it is first defined in users_router and so it wins over the one in
|
|
113
|
+
# fapi_users.get_users_router
|
|
114
|
+
users_router = APIRouter()
|
|
115
|
+
|
|
116
|
+
@users_router.get("/me")
|
|
117
|
+
async def get_me(
|
|
118
|
+
user: UserRead = Depends(backend.current_user(permissions={"admin": ["read"]})),
|
|
119
|
+
):
|
|
120
|
+
return user
|
|
121
|
+
|
|
122
|
+
users_router.include_router(backend.fapi_users.get_users_router(UserRead, UserUpdate))
|
|
123
|
+
|
|
124
|
+
# Cookie based auth login and logout
|
|
125
|
+
self.include_router(
|
|
126
|
+
backend.fapi_users.get_auth_router(backend.cookie_authentication), prefix="/auth"
|
|
127
|
+
)
|
|
128
|
+
self.include_router(
|
|
129
|
+
backend.fapi_users.get_register_router(UserRead, UserCreate),
|
|
130
|
+
prefix="/auth",
|
|
131
|
+
dependencies=[Depends(backend.current_user(permissions={"admin": ["write"]}))],
|
|
132
|
+
)
|
|
133
|
+
self.include_router(users_router, prefix="/auth/user")
|
|
134
|
+
|
|
135
|
+
# GitHub OAuth register router
|
|
136
|
+
self.include_router(
|
|
137
|
+
backend.fapi_users.get_oauth_router(
|
|
138
|
+
backend.github_authentication, backend.github_cookie_authentication, db.secret
|
|
139
|
+
),
|
|
140
|
+
prefix="/auth/github",
|
|
141
|
+
)
|
|
142
|
+
self.include_router(router)
|
|
143
|
+
|
|
144
|
+
self.create_user = create_user
|
|
145
|
+
self.__update_user = update_user
|
|
146
|
+
self.get_user_by_email = get_user_by_email
|
|
147
|
+
|
|
148
|
+
async def _update_user(self, user, **kwargs):
|
|
149
|
+
return await self.__update_user(user, **kwargs)
|
|
150
|
+
|
|
151
|
+
def current_user(self, permissions: dict[str, list[str]] | None = None) -> Callable:
|
|
152
|
+
return backend.current_user(permissions)
|
|
153
|
+
|
|
154
|
+
async def update_user(self, update_user=Depends(backend.update_user)) -> Callable:
|
|
155
|
+
return update_user
|
|
156
|
+
|
|
157
|
+
def websocket_auth(
|
|
158
|
+
self,
|
|
159
|
+
permissions: dict[str, list[str]] | None = None,
|
|
160
|
+
) -> Callable[[Any], Awaitable[tuple[Any, dict[str, list[str]] | None] | None]]:
|
|
161
|
+
return backend.websocket_auth(permissions)
|
|
162
|
+
|
|
163
|
+
return _Auth()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# From https://en.wikipedia.org/wiki/Moons_of_Jupiter
|
|
167
|
+
moons_of_jupyter = (
|
|
168
|
+
"Metis",
|
|
169
|
+
"Adrastea",
|
|
170
|
+
"Amalthea",
|
|
171
|
+
"Thebe",
|
|
172
|
+
"Io",
|
|
173
|
+
"Europa",
|
|
174
|
+
"Ganymede",
|
|
175
|
+
"Callisto",
|
|
176
|
+
"Themisto",
|
|
177
|
+
"Leda",
|
|
178
|
+
"Ersa",
|
|
179
|
+
"Pandia",
|
|
180
|
+
"Himalia",
|
|
181
|
+
"Lysithea",
|
|
182
|
+
"Elara",
|
|
183
|
+
"Dia",
|
|
184
|
+
"Carpo",
|
|
185
|
+
"Valetudo",
|
|
186
|
+
"Euporie",
|
|
187
|
+
"Eupheme",
|
|
188
|
+
# 'S/2003 J 18',
|
|
189
|
+
# 'S/2010 J 2',
|
|
190
|
+
"Helike",
|
|
191
|
+
# 'S/2003 J 16',
|
|
192
|
+
# 'S/2003 J 2',
|
|
193
|
+
"Euanthe",
|
|
194
|
+
# 'S/2017 J 7',
|
|
195
|
+
"Hermippe",
|
|
196
|
+
"Praxidike",
|
|
197
|
+
"Thyone",
|
|
198
|
+
"Thelxinoe",
|
|
199
|
+
# 'S/2017 J 3',
|
|
200
|
+
"Ananke",
|
|
201
|
+
"Mneme",
|
|
202
|
+
# 'S/2016 J 1',
|
|
203
|
+
"Orthosie",
|
|
204
|
+
"Harpalyke",
|
|
205
|
+
"Iocaste",
|
|
206
|
+
# 'S/2017 J 9',
|
|
207
|
+
# 'S/2003 J 12',
|
|
208
|
+
# 'S/2003 J 4',
|
|
209
|
+
"Erinome",
|
|
210
|
+
"Aitne",
|
|
211
|
+
"Herse",
|
|
212
|
+
"Taygete",
|
|
213
|
+
# 'S/2017 J 2',
|
|
214
|
+
# 'S/2017 J 6',
|
|
215
|
+
"Eukelade",
|
|
216
|
+
"Carme",
|
|
217
|
+
# 'S/2003 J 19',
|
|
218
|
+
"Isonoe",
|
|
219
|
+
# 'S/2003 J 10',
|
|
220
|
+
"Autonoe",
|
|
221
|
+
"Philophrosyne",
|
|
222
|
+
"Cyllene",
|
|
223
|
+
"Pasithee",
|
|
224
|
+
# 'S/2010 J 1',
|
|
225
|
+
"Pasiphae",
|
|
226
|
+
"Sponde",
|
|
227
|
+
# 'S/2017 J 8',
|
|
228
|
+
"Eurydome",
|
|
229
|
+
# 'S/2017 J 5',
|
|
230
|
+
"Kalyke",
|
|
231
|
+
"Hegemone",
|
|
232
|
+
"Kale",
|
|
233
|
+
"Kallichore",
|
|
234
|
+
# 'S/2011 J 1',
|
|
235
|
+
# 'S/2017 J 1',
|
|
236
|
+
"Chaldene",
|
|
237
|
+
"Arche",
|
|
238
|
+
"Eirene",
|
|
239
|
+
"Kore",
|
|
240
|
+
# 'S/2011 J 2',
|
|
241
|
+
# 'S/2003 J 9',
|
|
242
|
+
"Megaclite",
|
|
243
|
+
"Aoede",
|
|
244
|
+
# 'S/2003 J 23',
|
|
245
|
+
"Callirrhoe",
|
|
246
|
+
"Sinope",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_anonymous_username() -> str:
|
|
251
|
+
"""
|
|
252
|
+
Get a random user-name based on the moons of Jupyter.
|
|
253
|
+
This function returns names like "Anonymous Io" or "Anonymous Metis".
|
|
254
|
+
"""
|
|
255
|
+
return moons_of_jupyter[random.randint(0, len(moons_of_jupyter) - 1)]
|