Skip to content

Commit

Permalink
Add authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
davidbrochart committed Aug 17, 2022
1 parent 0f23e0b commit 5a39ddc
Show file tree
Hide file tree
Showing 21 changed files with 327 additions and 288 deletions.
13 changes: 5 additions & 8 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,16 @@ jobs:

- name: Install jupyverse
run: |
pip install fps[uvicorn]
pip install . --no-deps
pip install ./plugins/jupyterlab
pip install ./plugins/login
pip install ./plugins/auth
pip install ./plugins/contents
pip install ./plugins/kernels
pip install ./plugins/terminals
pip install ./plugins/lab
pip install ./plugins/nbconvert
pip install ./plugins/yjs
pip install ./plugins/lab
pip install ./plugins/jupyterlab
pip install "jupyter_ydoc >=0.1.16,<0.2.0" # FIXME: remove with next JupyterLab release
pip install "y-py >=0.5.4"
pip install mypy pytest pytest-asyncio requests ipykernel
pip install .[test]
- name: Check types
run: |
Expand Down
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ When switching e.g. from the JupyterLab to the RetroLab front-end, you need to
Clone this repository and install the needed plugins:

```bash
pip install fps[uvicorn]
pip install -e . --no-deps
pip install -e plugins/jupyterlab
pip install -e plugins/login
pip install -e plugins/auth
Expand All @@ -51,9 +49,9 @@ pip install -e plugins/terminals
pip install -e plugins/lab
pip install -e plugins/nbconvert
pip install -e plugins/yjs
pip install -e .[test]

# if you want RetroLab instead of JupyterLab:
# pip install -e . --no-deps
# pip install -e plugins/retrolab
# ...
```
Expand All @@ -63,7 +61,7 @@ pip install -e plugins/yjs
## Without authentication

```bash
jupyverse --open-browser --authenticator.mode=noauth
jupyverse --open-browser --auth.mode=noauth
```

This will open a browser at 127.0.0.1:8000 by default, and load the JupyterLab front-end.
Expand All @@ -72,7 +70,7 @@ You have full access to the API, without restriction.
## With token authentication

```bash
jupyverse --open-browser --authenticator.mode=token
jupyverse --open-browser --auth.mode=token
```

This is the default mode, and it corresponds to
Expand All @@ -81,7 +79,7 @@ This is the default mode, and it corresponds to
## With user authentication

```bash
jupyverse --open-browser --authenticator.mode=user
jupyverse --open-browser --auth.mode=user
```

We provide a JupyterLab extension for authentication, that you can install with:
Expand All @@ -96,7 +94,7 @@ You can currently authenticate as an anonymous user, or
## With collaborative editing

```bash
jupyverse --open-browser --authenticator.collaborative
jupyverse --open-browser --auth.collaborative
```

This is especially interesting if you are "user-authenticated", since your will appear as the
Expand Down
8 changes: 4 additions & 4 deletions binder/jupyter_notebook_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
[
"jupyverse",
"--no-open-browser",
"--authenticator.mode=noauth",
"--authenticator.collaborative",
"--auth.mode=noauth",
"--auth.collaborative",
"--RetroLab.enabled=false",
"--Lab.base_url={base_url}jupyverse-jlab/",
"--port={port}",
Expand All @@ -16,8 +16,8 @@
[
"jupyverse",
"--no-open-browser",
"--authenticator.mode=noauth",
"--authenticator.collaborative",
"--auth.mode=noauth",
"--auth.collaborative",
"--JupyterLab.enabled=false",
"--Lab.base_url={base_url}jupyverse-rlab/",
"--port={port}",
Expand Down
118 changes: 80 additions & 38 deletions plugins/auth/fps_auth/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Generic, Optional

import httpx
from fastapi import Depends, HTTPException, Response, status
from fastapi import Depends, HTTPException, Response, WebSocket, status
from fastapi_users import ( # type: ignore
BaseUserManager,
FastAPIUsers,
Expand Down Expand Up @@ -95,7 +95,7 @@ async def on_after_register(self, user: User, request: Optional[Request] = None)
anonymous=False,
username=r["login"],
color=None,
avatar=r["avatar_url"],
avatar_url=r["avatar_url"],
is_active=True,
),
)
Expand Down Expand Up @@ -135,39 +135,81 @@ async def create_guest(user_db, auth_config):
return await user_db.create(guest)


async def current_user(
response: Response,
token: Optional[str] = None,
user: User = Depends(
fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends)
),
user_db=Depends(get_user_db),
auth_config=Depends(get_auth_config),
):
active_user = user

if auth_config.collaborative:
if not active_user and auth_config.mode == "noauth":
active_user = await create_guest(user_db, auth_config)
await cookie_authentication.login(get_jwt_strategy(), active_user, response)

elif not active_user and auth_config.mode == "token":
global_user = await user_db.get_by_email(auth_config.global_email)
if global_user and global_user.hashed_password == token:
active_user = await create_guest(user_db, auth_config)
await cookie_authentication.login(get_jwt_strategy(), active_user, response)
else:
if auth_config.mode == "token":
global_user = await user_db.get_by_email(auth_config.global_email)
if global_user and global_user.hashed_password == token:
active_user = global_user
await cookie_authentication.login(get_jwt_strategy(), active_user, response)

if active_user:
return active_user

elif auth_config.login_url:
raise RedirectException(auth_config.login_url)

else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
def current_user(resource: Optional[str] = None):
async def _(
request: Request,
response: Response,
token: Optional[str] = None,
user: Optional[User] = Depends(
fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends)
),
user_db=Depends(get_user_db),
auth_config=Depends(get_auth_config),
):
if auth_config.mode == "user":
# "user" authentication: check authorization
if user and resource:
# check if allowed to access the resource
permissions = user.permissions.get(resource, [])
if request.method in ("GET", "HEAD"):
if "read" not in permissions:
user = None
elif request.method in ("POST", "PUT", "PATCH", "DELETE"):
if "write" not in permissions:
user = None
else:
# "noauth" or "token" authentication
if auth_config.collaborative:
if not user and auth_config.mode == "noauth":
user = await create_guest(user_db, auth_config)
await cookie_authentication.login(get_jwt_strategy(), user, response)

elif not user and auth_config.mode == "token":
global_user = await user_db.get_by_email(auth_config.global_email)
if global_user and global_user.hashed_password == token:
user = await create_guest(user_db, auth_config)
await cookie_authentication.login(get_jwt_strategy(), user, response)
else:
if auth_config.mode == "token":
global_user = await user_db.get_by_email(auth_config.global_email)
if global_user and global_user.hashed_password == token:
user = global_user
await cookie_authentication.login(get_jwt_strategy(), user, response)

if user:
return user

elif auth_config.login_url:
raise RedirectException(auth_config.login_url)

else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)

return _


def websocket_for_current_user(resource: str):
async def _(
websocket: WebSocket,
auth_config=Depends(get_auth_config),
user_manager: UserManager = Depends(get_user_manager),
) -> Optional[WebSocket]:
accept_websocket = False
if auth_config.mode == "noauth":
accept_websocket = True
elif "fastapiusersauth" in websocket._cookies:
token = websocket._cookies["fastapiusersauth"]
user = await get_jwt_strategy().read_token(token, user_manager)
if user:
if auth_config.mode == "user":
if "execute" in user.permissions.get(resource, []):
accept_websocket = True
else:
accept_websocket = True
if accept_websocket:
return websocket
else:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return None

return _
11 changes: 7 additions & 4 deletions plugins/auth/fps_auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
from uuid import uuid4

from fps.config import PluginModel, get_config # type: ignore
from fps.hooks import register_config, register_plugin_name # type: ignore
from pydantic import SecretStr
from fps.hooks import register_config # type: ignore
from pydantic import BaseSettings, SecretStr


class AuthConfig(PluginModel):
class AuthConfig(PluginModel, BaseSettings):
client_id: str = ""
client_secret: SecretStr = SecretStr("")
redirect_uri: str = ""
Expand All @@ -17,12 +17,15 @@ class AuthConfig(PluginModel):
global_email: str = "[email protected]"
cookie_secure: bool = False # FIXME: should default to True, and set to False for tests
clear_users: bool = False
test: bool = False
login_url: Optional[str] = None

class Config(PluginModel.Config):
env_prefix = "fps_auth_"


def get_auth_config():
return get_config(AuthConfig)


c = register_config(AuthConfig)
n = register_plugin_name("authenticator")
25 changes: 14 additions & 11 deletions plugins/auth/fps_auth/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
SQLAlchemyUserDatabase,
)
from fps.config import get_config # type: ignore
from sqlalchemy import Boolean, Column, String, Text # type: ignore
from sqlalchemy import JSON, Boolean, Column, String, Text # type: ignore
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # type: ignore
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base # type: ignore
from sqlalchemy.orm import relationship, sessionmaker # type: ignore
Expand All @@ -20,8 +20,11 @@

jupyter_dir = Path.home() / ".local" / "share" / "jupyter"
jupyter_dir.mkdir(parents=True, exist_ok=True)
secret_path = jupyter_dir / "jupyverse_secret"
userdb_path = jupyter_dir / "jupyverse_users.db"
name = "jupyverse"
if auth_config.test:
name += "_test"
secret_path = jupyter_dir / f"{name}_secret"
userdb_path = jupyter_dir / f"{name}_users.db"

if auth_config.clear_users:
if userdb_path.is_file():
Expand All @@ -30,11 +33,9 @@
secret_path.unlink()

if not secret_path.is_file():
with open(secret_path, "w") as f:
f.write(secrets.token_hex(32))
secret_path.write_text(secrets.token_hex(32))

with open(secret_path) as f:
secret = f.read()
secret = secret_path.read_text()


DATABASE_URL = f"sqlite+aiosqlite:///{userdb_path}"
Expand All @@ -48,13 +49,15 @@ class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
class User(SQLAlchemyBaseUserTableUUID, Base):
anonymous = Column(Boolean, default=True, nullable=False)
email = Column(String(length=32), nullable=False, unique=True)
username = Column(String(length=32), nullable=True, unique=True)
name = Column(String(length=32), nullable=True)
username = Column(String(length=32), nullable=False, unique=True)
name = Column(String(length=32), default="")
display_name = Column(String(length=32), default="")
initials = Column(String(length=8), nullable=True)
color = Column(String(length=32), nullable=True)
avatar = Column(String(length=32), nullable=True)
avatar_url = Column(String(length=32), nullable=True)
workspace = Column(Text(), default="{}", nullable=False)
settings = Column(Text(), default="{}", nullable=False)
permissions = Column(Text(), default="{}", nullable=False)
permissions = Column(JSON, default={}, nullable=False)
oauth_accounts: List[OAuthAccount] = relationship("OAuthAccount", lazy="joined")


Expand Down
Loading

0 comments on commit 5a39ddc

Please sign in to comment.