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

Token invalidation #429

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
8 changes: 6 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<email>
DATA_DIR=.clients/$EMAIL
mkdir -p $DATA_DIR
Expand All @@ -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

# ---------------------------------------------------------------------------------------------------------------------

Expand Down
22 changes: 18 additions & 4 deletions syftbox/client/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def has_valid_access_token(conf: SyftClientConfig, auth_client: httpx.Client) ->
rprint(f"[red]An unexpected error occurred: {response.text}, re-authenticating.[/red]")
return False

authed_email = response.text
authed_email = response.text[1:-1]
is_valid = authed_email == conf.email
if not is_valid:
rprint(
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -81,6 +83,18 @@ def get_access_token(
rprint(f"[red]An unexpected error occurred: {response.text}[/red]")
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) -> str:
auth_client = httpx.Client(base_url=str(conf.server_url))
Expand Down
64 changes: 7 additions & 57 deletions syftbox/client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,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
Expand All @@ -87,6 +85,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"""

Expand All @@ -107,7 +106,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)

Expand Down Expand Up @@ -139,56 +139,6 @@ def report(
rprint(f"[red]Error[/red]: {e}")
raise Exit(1)

@app.command()
def forgot_password(
email: Annotated[str, EMAIL_OPTS],
server: Annotated[str, SERVER_OPTS] = DEFAULT_SERVER_URL,
config_path: Annotated[Path, CONFIG_OPTS] = DEFAULT_CONFIG_PATH,
):
from syftbox.lib.client_config import SyftClientConfig
from syftbox.client.cli_setup import prompt_email
config: SyftClientConfig = None

try:
config = SyftClientConfig.load(config_path)
except:
pass

server_url = config.server_url if config else server

response = httpx.post(
f"{server_url}users/reset_password",
data={"email": email},
)
response.raise_for_status()
rprint("Forgot password request sent succesfully! Check your email!")

@app.command()
def reset_password(
token: Annotated[str, TOKEN_OPTS],
email: Annotated[str, EMAIL_OPTS],
server: Annotated[str, SERVER_OPTS] = DEFAULT_SERVER_URL,
config_path: Annotated[Path, CONFIG_OPTS] = DEFAULT_CONFIG_PATH,
):
from syftbox.lib.client_config import SyftClientConfig
from syftbox.client.cli_setup import prompt_password
config: SyftClientConfig = None

try:
config = SyftClientConfig.load(config_path)
except:
pass

server_url = config.server_url if config else server
new_password = prompt_password()

response = httpx.post(
f"{server_url}users/change_password",
data={"email": email, "token": token, "new_password": new_password},
)
response.raise_for_status()
rprint("Password updated succesfully!")


def main():
app()
Expand Down
16 changes: 14 additions & 2 deletions syftbox/client/cli_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,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
Expand Down Expand Up @@ -61,7 +61,13 @@ def get_migration_decision(data_dir: Path):


def setup_config_interactive(
config_path: Path, email: str, data_dir: Path, server: str, port: int, skip_auth: bool = False
config_path: Path,
email: str,
data_dir: Path,
server: str,
port: int,
skip_auth: bool = False,
reset_token: bool = False,
) -> SyftClientConfig:
"""Setup the client configuration interactively. Called from CLI"""

Expand Down Expand Up @@ -98,6 +104,12 @@ def setup_config_interactive(
if port != conf.client_url.port:
conf.set_port(port)

rprint(f"[bold]{reset_token}, {conf.access_token}[/bold]")
if reset_token:
if conf.access_token:
invalidate_client_token(conf)
conf.access_token = None

if not skip_auth:
conf.access_token = authenticate_user(conf)

Expand Down
7 changes: 7 additions & 0 deletions syftbox/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,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):
Expand All @@ -149,6 +155,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")
Expand Down
10 changes: 9 additions & 1 deletion syftbox/server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ServerSettings(BaseSettings):

data_folder: Path = Field(default=Path("data").resolve())
"""Absolute path to the server data folder"""

email_service_api_key: str = Field(default="")
"""API key for the email service"""

Expand Down Expand Up @@ -71,6 +71,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:
Expand Down
43 changes: 42 additions & 1 deletion syftbox/server/sync/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
29 changes: 27 additions & 2 deletions syftbox/server/users/auth.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -144,6 +146,20 @@ def generate_email_token(server_settings: ServerSettings, email: str) -> str:

def validate_access_token(server_settings: ServerSettings, token: str) -> dict:
data = validate_token(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,
Expand Down Expand Up @@ -171,10 +187,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"]
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=""))
Loading