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

Login page #75

Merged
merged 8 commits into from
Oct 18, 2021
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
1 change: 1 addition & 0 deletions .github/workflows/check-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
pip install -e plugins/nbconvert
pip install -e plugins/yjs
pip install -e plugins/auth
pip install -e plugins/login
- name: Check Release
if: ${{ matrix.group == 'check_release' }}
uses: davidbrochart/jupyter_releaser/.github/actions/check-release@py_multi_package
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ jobs:
run: |
mkdir fps && cd fps && curl -L -O https://github.com/jupyter-server/fps/archive/master.tar.gz && tar zxf master.tar.gz && cd fps-master && pip install . && pip install ./plugins/uvicorn && cd ../.. && rm -rf fps
pip install . --no-deps
pip install ./plugins/contents
pip install ./plugins/auth
pip install ./plugins/contents
pip install ./plugins/kernels
pip install ./plugins/nbconvert
pip install ./plugins/yjs
pip install ./plugins/lab
pip install ./plugins/jupyterlab

pip install flake8 black mypy pytest pytest-asyncio requests ipykernel

- name: Check style
Expand All @@ -59,6 +62,7 @@ jobs:
mypy plugins/nbconvert/fps_nbconvert
mypy plugins/yjs/fps_yjs
mypy plugins/terminals/fps_terminals
mypy plugins/login/fps_login

- name: Run tests
run: |
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ Clone this repository and install the needed plugins:

```bash
pip install -e . --no-deps
pip install -e plugins/jupyterlab
pip install -e plugins/login
pip install -e plugins/auth
pip install -e plugins/contents
pip install -e plugins/kernels
pip install -e plugins/terminals
pip install -e plugins/lab
pip install -e plugins/jupyterlab
pip install -e plugins/nbconvert
pip install -e plugins/yjs
pip install -e plugins/auth

# you should also install the latest FPS:
pip install git+https://github.com/jupyter-server/fps
Expand Down Expand Up @@ -87,7 +89,7 @@ You can currently authenticate as an anonymous user, or
## With collaborative editing

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

This is especially interesting if you are "user-authenticated", since your will appear as the
Expand Down
5 changes: 0 additions & 5 deletions fps.toml

This file was deleted.

173 changes: 105 additions & 68 deletions plugins/auth/fps_auth/backends.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,58 @@
from uuid import uuid4
from typing import Optional

from fps.exceptions import RedirectException # type: ignore

import httpx
from fastapi import Depends, HTTPException, status
from fastapi_users.authentication import BaseAuthentication, CookieAuthentication # type: ignore
from httpx_oauth.clients.github import GitHubOAuth2 # type: ignore
from fastapi import Depends, Response, HTTPException, status

from fastapi_users.authentication import CookieAuthentication, BaseAuthentication # type: ignore
from fastapi_users import FastAPIUsers, BaseUserManager # type: ignore
from starlette.requests import Request
from fps.exceptions import RedirectException # type: ignore

from .models import (
User,
UserCreate,
UserUpdate,
UserDB,
)
from fps.logging import get_configured_logger # type: ignore

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


NOAUTH_EMAIL = "[email protected]"
NOAUTH_USER = UserDB(
id="d4ded46b-a4df-4b51-8d83-ae19010272a7",
email=NOAUTH_EMAIL,
hashed_password="",
)
logger = get_configured_logger("auth")


class NoAuthAuthentication(BaseAuthentication):
def __init__(self, user: UserDB, name: str = "noauth"):
def __init__(self, name: str = "noauth"):
super().__init__(name, logout=False)
self.user = user
self.scheme = None # type: ignore

async def __call__(self, credentials, user_manager):
noauth_user = await user_manager.user_db.get_by_email(NOAUTH_EMAIL)
return noauth_user or self.user
active_user = await user_manager.user_db.get_by_email(
get_auth_config().global_email
)
return active_user


class GitHubAuthentication(CookieAuthentication):
async def get_login_response(self, user, response, user_manager):
await super().get_login_response(user, response, user_manager)
response.status_code = status.HTTP_302_FOUND
response.headers["Location"] = "/lab"


noauth_authentication = NoAuthAuthentication(NOAUTH_USER)
noauth_authentication = NoAuthAuthentication(name="noauth")
cookie_authentication = CookieAuthentication(
secret=secret, cookie_secure=get_auth_config().cookie_secure, name="cookie" # type: ignore
)
github_cookie_authentication = GitHubAuthentication(secret=secret, name="github")
github_authentication = GitHubOAuth2(
get_auth_config().client_id, get_auth_config().client_secret.get_secret_value()
)


class UserManager(BaseUserManager[UserCreate, UserDB]):
user_db_model = UserDB

async def on_after_register(self, user: UserDB, request: Optional[Request] = None):
user.initialized = True
for oauth_account in user.oauth_accounts:
if oauth_account.oauth_name == "github":
async with httpx.AsyncClient() as client:
Expand All @@ -52,69 +61,97 @@ async def on_after_register(self, user: UserDB, request: Optional[Request] = Non
f"https://api.github.com/user/{oauth_account.account_id}"
)
).json()

user.anonymous = False
user.username = r["login"]
user.name = r["name"]
user.color = None
user.avatar = r["avatar_url"]
await self.user_db.update(user)


class LoginCookieAuthentication(CookieAuthentication):
async def get_login_response(self, user, response, user_manager):
await super().get_login_response(user, response, user_manager)
# set user as logged in
user.logged_in = True
await user_manager.user_db.update(user)
# auto redirect
response.status_code = status.HTTP_302_FOUND
response.headers["Location"] = "/lab"

async def get_logout_response(self, user, response, user_manager):
await super().get_logout_response(user, response, user_manager)
# set user as logged out
user.logged_in = False
await user_manager.user_db.update(user)

user.workspace = "{}"
user.settings = "{}"

cookie_authentication = LoginCookieAuthentication(
cookie_secure=get_auth_config().cookie_secure, secret=secret
)
await self.user_db.update(user)


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


users = FastAPIUsers(
async def get_enabled_backends(auth_config=Depends(get_auth_config)):
if auth_config.mode == "noauth" and not auth_config.collaborative:
return [noauth_authentication, github_cookie_authentication]
else:
return [cookie_authentication, github_cookie_authentication]


fapi_users = FastAPIUsers(
get_user_manager,
[noauth_authentication, cookie_authentication],
[noauth_authentication, cookie_authentication, github_cookie_authentication],
User,
UserCreate,
UserUpdate,
UserDB,
)


async def get_enabled_backends(auth_config=Depends(get_auth_config)):
if auth_config.mode == "noauth":
return [noauth_authentication]
return [cookie_authentication]


def current_user():
async def _(
user: User = Depends(
users.current_user(optional=True, get_enabled_backends=get_enabled_backends)
),
auth_config=Depends(get_auth_config),
):
if user is None:
if auth_config.login_url:
raise RedirectException(auth_config.login_url)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
else:
return user

return _
async def create_guest(user_db, auth_config=Depends(get_auth_config)):
global_user = await user_db.get_by_email(auth_config.global_email)
user_id = str(uuid4())
guest = UserDB(
id=user_id,
anonymous=True,
email=f"{user_id}@jupyter.com",
username=f"{user_id}@jupyter.com",
hashed_password="",
workspace=global_user.workspace,
settings=global_user.settings,
)
await user_db.create(guest)
return 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),
user_manager: UserManager = Depends(get_user_manager),
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)
await cookie_authentication.get_login_response(
active_user, response, user_manager
)

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)
await cookie_authentication.get_login_response(
active_user, response, user_manager
)
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.get_login_response(
active_user, response, user_manager
)

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)
11 changes: 8 additions & 3 deletions plugins/auth/fps_auth/config.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
from uuid import uuid4
from typing import Literal, Optional
from pydantic import SecretStr

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 typing import Literal


class AuthConfig(PluginModel):
client_id: str = ""
client_secret: SecretStr = SecretStr("")
redirect_uri: str = ""
mode: Literal["noauth", "token", "user"] = "token"
token: str = str(uuid4())
collaborative: bool = False
global_email: str = "[email protected]"
cookie_secure: bool = (
False # FIXME: should default to True, and set to False for tests
)
clear_users: bool = False
login_url: str = "/login_page"
login_url: Optional[str] = None


def get_auth_config():
Expand Down
7 changes: 3 additions & 4 deletions plugins/auth/fps_auth/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@


class UserTable(Base, SQLAlchemyBaseUserTable):
initialized = Column(Boolean, default=False, nullable=False)
anonymous = Column(Boolean, default=False, nullable=False)
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=True)
color = Column(String(length=32), nullable=True)
avatar = Column(String(length=32), nullable=True)
logged_in = Column(Boolean, default=False, nullable=False)
workspace = Column(Text(), nullable=False)
settings = Column(Text(), nullable=False)

Expand Down
7 changes: 3 additions & 4 deletions plugins/auth/fps_auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@


class JupyterUser(BaseModel):
initialized: bool = False
anonymous: bool = True
username: str = ""
name: Optional[str] = None
username: Optional[str] = None
color: Optional[str] = None
avatar: Optional[str] = None
logged_in: bool = False
workspace: str = "{}"
settings: str = "{}"

Expand All @@ -21,8 +19,9 @@ class User(models.BaseUser, models.BaseOAuthAccountMixin, JupyterUser):


class UserCreate(models.BaseUserCreate):
name: Optional[str] = None
anonymous: bool = True
username: Optional[str] = None
name: Optional[str] = None
color: Optional[str] = None


Expand Down
Loading