Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add authorization #206

Merged
merged 4 commits into from
Aug 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
134 changes: 88 additions & 46 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 All @@ -24,6 +24,7 @@

from .config import get_auth_config
from .db import User, get_user_db, secret
from .models import UserCreate

logger = get_configured_logger("auth")

Expand Down Expand Up @@ -95,13 +96,13 @@ 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,
),
)


def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)


Expand All @@ -118,56 +119,97 @@ async def get_enabled_backends(auth_config=Depends(get_auth_config)):
)


async def create_guest(user_db, auth_config):
async def create_guest(user_manager, auth_config):
# workspace and settings are copied from global user
# but this is a new user
global_user = await user_db.get_by_email(auth_config.global_email)
global_user = await user_manager.get_by_email(auth_config.global_email)
user_id = str(uuid.uuid4())
guest = dict(
id=user_id,
anonymous=True,
email=f"{user_id}@jupyter.com",
username=f"{user_id}@jupyter.com",
hashed_password="",
password="",
workspace=global_user.workspace,
settings=global_user.settings,
)
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)
return await user_manager.create(UserCreate(**guest))


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_manager: UserManager = Depends(get_user_manager),
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_manager, auth_config)
await cookie_authentication.login(get_jwt_strategy(), user, response)

elif not user and auth_config.mode == "token":
global_user = await user_manager.get_by_email(auth_config.global_email)
if global_user and global_user.hashed_password == token:
user = await create_guest(user_manager, auth_config)
await cookie_authentication.login(get_jwt_strategy(), user, response)
else:
if auth_config.mode == "token":
global_user = await user_manager.get_by_email(auth_config.global_email)
if global_user and global_user.username == 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")
38 changes: 16 additions & 22 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,17 +49,20 @@ 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(JSON, default={}, nullable=False)
oauth_accounts: List[OAuthAccount] = relationship("OAuthAccount", lazy="joined")


engine = create_async_engine(DATABASE_URL)
Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


async def create_db_and_tables():
Expand All @@ -67,19 +71,9 @@ async def create_db_and_tables():


async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with Session() as session:
async with async_session_maker() as session:
yield session


async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User, OAuthAccount)


class UserDb:
async def __aenter__(self):
self.session = Session()
session = await self.session.__aenter__()
return SQLAlchemyUserDatabase(session, User, OAuthAccount)

async def __aexit__(self, exc_type, exc_value, exc_tb):
return await self.session.__aexit__(exc_type, exc_value, exc_tb)
Loading