diff --git a/justfile b/justfile index 1a8b1fe6..680bc44f 100644 --- a/justfile +++ b/justfile @@ -43,7 +43,7 @@ run-server port="5001" uvicorn_args="": # Run a local syftbox client on any available port between 8080-9000 [group('client')] -run-client name port="auto" server="http://localhost:5001": +run-client name port="auto" server="http://localhost:5001" reset-token="false": #!/bin/bash set -eou pipefail @@ -55,6 +55,9 @@ run-client name port="auto" server="http://localhost:5001": PORT="{{ port }}" if [[ "$PORT" == "auto" ]]; then PORT="0"; fi + RESET_TOKEN="" + if [[ "{{ reset-token }}" == "true" ]]; then RESET_TOKEN="--reset-token"; fi + # Working directory for client is .clients/ DATA_DIR=.clients/$EMAIL mkdir -p $DATA_DIR @@ -63,8 +66,9 @@ run-client name port="auto" server="http://localhost:5001": echo -e "Client : {{ _cyan }}http://localhost:$PORT{{ _nc }}" echo -e "Server : {{ _cyan }}{{ server }}{{ _nc }}" echo -e "Data Dir : $DATA_DIR" + echo -e "Reset Token: {{ reset-token }}" - uv run syftbox/client/cli.py --config=$DATA_DIR/config.json --data-dir=$DATA_DIR --email=$EMAIL --port=$PORT --server={{ server }} --no-open-dir + uv run syftbox/client/cli.py --config=$DATA_DIR/config.json --data-dir=$DATA_DIR --email=$EMAIL --port=$PORT --server={{ server }} --no-open-dir $RESET_TOKEN # --------------------------------------------------------------------------------------------------------------------- diff --git a/syftbox/client/auth.py b/syftbox/client/auth.py index 0ddce36d..f296da1d 100644 --- a/syftbox/client/auth.py +++ b/syftbox/client/auth.py @@ -45,6 +45,10 @@ def request_email_token(auth_client: httpx.Client, conf: SyftClientConfig) -> Op response.raise_for_status() return response.json().get("email_token", None) +def prompt_get_token_from_email(email): + return Prompt.ask( + f"[yellow]Please enter the token sent to {email}. Also check your spam folder[/yellow]" + ) def get_access_token( conf: SyftClientConfig, @@ -63,9 +67,7 @@ def get_access_token( str: access token """ if not email_token: - email_token = Prompt.ask( - f"[yellow]Please enter the token sent to {conf.email}. Also check your spam folder[/yellow]" - ) + email_token = prompt_get_token_from_email(conf.email) response = auth_client.post( "/auth/validate_email_token", @@ -82,6 +84,18 @@ def get_access_token( rprint(f"[red]An unexpected error occurred: {response.text}[/red]") raise typer.Exit(1) +def invalidate_client_token(conf: SyftClientConfig): + auth_client = httpx.Client(base_url=str(conf.server_url)) + + if has_valid_access_token(conf, auth_client): + response = auth_client.post( + "/auth/invalidate_access_token", + headers={"Authorization": f"Bearer {conf.access_token}"}, + ) + rprint(f"[bold]{response.text}[/bold]") + else: + rprint("[yellow]No valid access token found, skipping token reset[/yellow]") + def authenticate_user(conf: SyftClientConfig, login_client: httpx.Client) -> str: if has_valid_access_token(conf, login_client): diff --git a/syftbox/client/cli.py b/syftbox/client/cli.py index 6bfca1da..a7491add 100644 --- a/syftbox/client/cli.py +++ b/syftbox/client/cli.py @@ -59,12 +59,10 @@ is_flag=True, help="Enable verbose mode", ) - - - -TOKEN_OPTS = Option( - "--token", - help="Token for password reset", +RESET_TOKEN_OPTS = Option( + "--reset-token", + is_flag=True, + help="Reset Token in order to invalidate the current one", ) # report command opts @@ -86,6 +84,7 @@ def client( port: Annotated[int, PORT_OPTS] = DEFAULT_PORT, open_dir: Annotated[bool, OPEN_OPTS] = True, verbose: Annotated[bool, VERBOSE_OPTS] = False, + reset_token: Annotated[bool, RESET_TOKEN_OPTS] = False, ): """Run the SyftBox client""" @@ -106,7 +105,8 @@ def client( rprint(f"[bold red]Error:[/bold red] Client cannot start because port {port} is already in use!") raise Exit(1) - client_config = setup_config_interactive(config_path, email, data_dir, server, port) + print(f"{reset_token=}") + client_config = setup_config_interactive(config_path, email, data_dir, server, port, reset_token=reset_token) migrate_datasite = get_migration_decision(client_config.data_dir) diff --git a/syftbox/client/cli_setup.py b/syftbox/client/cli_setup.py index 6727fd23..610e969b 100644 --- a/syftbox/client/cli_setup.py +++ b/syftbox/client/cli_setup.py @@ -12,7 +12,7 @@ from rich.prompt import Confirm, Prompt from syftbox.__version__ import __version__ -from syftbox.client.auth import authenticate_user +from syftbox.client.auth import authenticate_user, invalidate_client_token from syftbox.client.client2 import METADATA_FILENAME from syftbox.lib.client_config import SyftClientConfig from syftbox.lib.constants import DEFAULT_DATA_DIR @@ -72,12 +72,13 @@ def get_migration_decision(data_dir: Path): def setup_config_interactive( - config_path: Path, - email: str, - data_dir: Path, - server: str, - port: int, + config_path: Path, + email: str, + data_dir: Path, + server: str, + port: int, skip_auth: bool = False, + reset_token: bool = False, skip_verify_install: bool = False, ) -> SyftClientConfig: """Setup the client configuration interactively. Called from CLI""" @@ -115,6 +116,11 @@ def setup_config_interactive( if port != conf.client_url.port: conf.set_port(port) + if reset_token: + if conf.access_token: + invalidate_client_token(conf) + conf.access_token = None + # Short-lived client for all pre-authentication requests login_client = httpx.Client(base_url=str(conf.server_url)) if not skip_verify_install: diff --git a/syftbox/server/server.py b/syftbox/server/server.py index 7b748fa4..22648ee8 100644 --- a/syftbox/server/server.py +++ b/syftbox/server/server.py @@ -135,6 +135,12 @@ def init_db(settings: ServerSettings) -> None: con.commit() con.close() +def touch(path): + with open(path, 'a'): + os.utime(path, None) + +def init_banned_file(path): + touch(path) @contextlib.asynccontextmanager async def lifespan(app: FastAPI, settings: Optional[ServerSettings] = None): @@ -150,6 +156,7 @@ async def lifespan(app: FastAPI, settings: Optional[ServerSettings] = None): logger.info("> Creating Folders") create_folders(settings.folders) + init_banned_file(settings.banned_tokens_path) users = Users(path=settings.user_file_path) logger.info("> Loading Users") diff --git a/syftbox/server/settings.py b/syftbox/server/settings.py index 6e8e28aa..09469395 100644 --- a/syftbox/server/settings.py +++ b/syftbox/server/settings.py @@ -77,6 +77,14 @@ def logs_folder(self) -> Path: @property def user_file_path(self) -> Path: return self.data_folder / "users.json" + + @property + def banned_tokens_path(self) -> Path: + return self.data_folder / "banned_tokens" + + @property + def banned_users_path(self) -> Path: + return self.data_folder / "banned_users" @classmethod def from_data_folder(cls, data_folder: Union[Path, str]) -> Self: diff --git a/syftbox/server/sync/db.py b/syftbox/server/sync/db.py index 879a4047..cae4d8fe 100644 --- a/syftbox/server/sync/db.py +++ b/syftbox/server/sync/db.py @@ -3,7 +3,7 @@ import sqlite3 import tempfile from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from syftbox.server.settings import ServerSettings from syftbox.server.sync.models import FileMetadata @@ -28,9 +28,50 @@ def get_db(path: str): file_size INTEGER NOT NULL, last_modified TEXT NOT NULL ) """) + # Create the table if it doesn't exist + conn.execute(""" + CREATE TABLE IF NOT EXISTS users_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_email TEXT NOT NULL UNIQUE, + credentials TEXT NOT NULL ) + """) return conn +def get_user_by_email(conn: sqlite3.Connection, email: str): + conn.execute("SELECT id, user_email, credentials FROM users_credentials WHERE user_email = ?", (email,)) + user = conn.fetchone() + return user + + +# maybe we should do update_user instead of set_credentials? +def set_credentials(conn: sqlite3.Connection, email: str, credentials: str): + conn.execute(""" + UPDATE users_credentials + SET credentials = ? + WHERE user_email = ? + """, (credentials, email,)) + + +def add_user(conn: sqlite3.Connection, email: str, credentials: str): + conn.execute(""" + INSERT INTO users_credentials (user_email, credentials) + VALUES (?, ?) + """, (email, credentials)) + + +def get_all_users(conn: sqlite3.Connection): + conn.execute("SELECT * FROM users_credentials") + return conn.fecthall() + + +def delete_user(conn: sqlite3.Connection, email: str): + conn.execute(""" + DELETE FROM users_credentials + WHERE user_email = ? + """, (email,)) + + def save_file_metadata(conn: sqlite3.Connection, metadata: FileMetadata): # Insert the metadata into the database or update if a conflict on 'path' occurs conn.execute( diff --git a/syftbox/server/users/auth.py b/syftbox/server/users/auth.py index bf2afeb9..26edef0b 100644 --- a/syftbox/server/users/auth.py +++ b/syftbox/server/users/auth.py @@ -1,12 +1,14 @@ import base64 from datetime import datetime, timezone import json +from pathlib import Path from typing_extensions import Annotated from fastapi import Depends, HTTPException, Header, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import httpx import jwt from syftbox.server.settings import ServerSettings, get_server_settings +from syftbox.server.users.user_store import User, UserStore bearer_scheme = HTTPBearer() @@ -66,6 +68,20 @@ def generate_email_token(server_settings: ServerSettings, email: str) -> str: def validate_access_token(server_settings: ServerSettings, token: str) -> dict: data = _validate_jwt(server_settings, token) + user_store = UserStore(server_settings=server_settings) + user = user_store.get_user_by_email(data['email']) + if not user: + raise HTTPException( + status_code=404, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + if user.credentials != token: + raise HTTPException( + status_code=401, + detail="Invalid Token", + headers={"WWW-Authenticate": "Bearer"}, + ) if data["type"] != ACCESS_TOKEN: raise HTTPException( status_code=401, @@ -93,10 +109,19 @@ def get_user_from_email_token( payload = validate_email_token(server_settings, credentials.credentials) return payload["email"] - def get_current_user( credentials: Annotated[HTTPAuthorizationCredentials, Security(bearer_scheme)], server_settings: Annotated[ServerSettings, Depends(get_server_settings)], ) -> str: payload = validate_access_token(server_settings, credentials.credentials) - return payload["email"] \ No newline at end of file + return payload["email"] + + +def set_token(server_settings: ServerSettings, email: str, token: str): + user_store = UserStore(server_settings=server_settings) + user_store.update_user(User(email=email, credentials=token)) + + +def delete_token(server_settings: ServerSettings, email: str): + user_store = UserStore(server_settings=server_settings) + user_store.update_user(User(email=email, credentials="")) \ No newline at end of file diff --git a/syftbox/server/users/router.py b/syftbox/server/users/router.py index cadd1c05..fefc0045 100644 --- a/syftbox/server/users/router.py +++ b/syftbox/server/users/router.py @@ -5,7 +5,8 @@ from syftbox.lib.email import send_token_email from syftbox.server.analytics import log_analytics_event from syftbox.server.settings import ServerSettings, get_server_settings -from syftbox.server.users.auth import generate_access_token, generate_email_token, get_user_from_email_token, get_current_user +from syftbox.server.users.auth import delete_token, generate_access_token, generate_email_token, get_user_from_email_token, get_current_user, set_token +from syftbox.server.users.user_store import User, UserStore router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -30,6 +31,12 @@ def get_token(req: EmailTokenRequest, server_settings: ServerSettings = Depends( email = req.email token = generate_email_token(server_settings, email) + user_store = UserStore(server_settings=server_settings) + if not user_store.exists(email=email): + user_store.add_user(User(email=email, credentials="")) + else: + user_store.update_user(User(email=email, credentials="")) + response = EmailTokenResponse() if server_settings.auth_enabled: send_token_email(server_settings, email, token) @@ -56,15 +63,44 @@ def validate_email_token( Returns: AccessTokenResponse: access token """ - if email_from_token != email: - raise HTTPException(status_code=401, detail="This email token is not for this email address") - + user_store = UserStore(server_settings=server_settings) + user = user_store.get_user_by_email(email=email) access_token = generate_access_token(server_settings, email) + if user: + if user.credentials is not None: + set_token(server_settings, email, access_token) + else: + # what happens if there is already some credentials set? + # it looks like if someone steals the email token, they can generate the access token + pass + else: + raise HTTPException(status_code=404, detail="User not found! Please register") return AccessTokenResponse(access_token=access_token) class WhoAmIResponse(BaseModel): email: str +@router.post("/invalidate_access_token") +def invalidate_access_token( + email: str = Depends(get_current_user), + server_settings: ServerSettings = Depends(get_server_settings), +) -> str: + """ + Invalidate the access token/ + + Args: + email (str, optional): The user email, extracted from the access token in the Authorization header. + Defaults to Depends(get_current_user). + + server_settings (ServerSettings, optional): server settings. Defaults to Depends(get_server_settings). + + Returns: + str: message + """ + delete_token(server_settings, email) + return "Token invalidation succesful!" + + @router.post("/whoami") def whoami( email: str = Depends(get_current_user), diff --git a/syftbox/server/users/user_store.py b/syftbox/server/users/user_store.py new file mode 100644 index 00000000..bcb7e9a6 --- /dev/null +++ b/syftbox/server/users/user_store.py @@ -0,0 +1,70 @@ +from pydantic import BaseModel, EmailStr +from syftbox.server.settings import ServerSettings +from syftbox.server.sync import db +from syftbox.server.sync.db import get_db +from syftbox.server.sync.models import AbsolutePath, RelativePath + + +class User(BaseModel): + email: EmailStr + credentials: str + + +class UserStore: + def __init__(self, server_settings: ServerSettings) -> None: + self. server_settings = server_settings + + @property + def db_path(self) -> AbsolutePath: + return self.server_settings.file_db_path + + def exists(self, email: str): + user = self.get_user_by_email(email=email) + if user: + return True + return False + + def delete_user(self, email: str): + conn = get_db(self.db_path) + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + try: + db.delete_user(cursor, email) + except ValueError: + pass + conn.commit() + cursor.close() + + def get_user_by_email(self, email: str): + with get_db(self.db_path) as conn: + cursor = conn.cursor() + user = db.get_user_by_email(cursor, email) + # ignoring id for now + return User(email=user[1], credentials=user[2]) + + def get_all_users(self): + with get_db(self.db_path) as conn: + cursor = conn.cursor() + users = db.get_all_users(cursor) + return users + + def add_user(self, user: User): + conn = get_db(self.db_path) + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + db.add_user(cursor, user.email, user.credentials) + conn.commit() + cursor.close() + conn.close() + + def update_user(self, user: User): + conn = get_db(self.db_path) + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + db.set_credentials(cursor, user.email, user.credentials) + conn.commit() + cursor.close() + conn.close() + + + \ No newline at end of file