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 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 7 additions & 8 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 @@ -140,7 +140,6 @@ def report(
raise Exit(1)



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
39 changes: 38 additions & 1 deletion syftbox/server/users/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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
Expand Down Expand Up @@ -114,6 +115,12 @@ def validate_token(server_settings: ServerSettings, token: str) -> dict:
Returns:
dict: decoded payload
"""
if check_line_in_file(token, server_settings.banned_tokens_path):
raise HTTPException(
status_code=401,
detail="Invalid Token",
headers={"WWW-Authenticate": "Bearer"},
)
if not server_settings.auth_enabled:
return _validate_base64(server_settings, token)
else:
Expand Down Expand Up @@ -171,10 +178,40 @@ def get_user_from_email_token(
payload = validate_email_token(server_settings, credentials.credentials)
return payload["email"]

def get_access_token(
credentials: Annotated[HTTPAuthorizationCredentials, Security(bearer_scheme)],
server_settings: Annotated[ServerSettings, Depends(get_server_settings)],
) -> str:
_ = validate_access_token(server_settings, credentials.credentials)
return credentials.credentials

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 check_line_in_file(line: str, file_path: Path) -> bool:
with open(file_path, 'r') as f:
for file_line in f:
if file_line == line:
return True
return False

def write_line_in_file(line: str, file_path: Path):
with open(file_path, 'a') as f:
f.writelines([line])

def delete_line_from_file(line: str, file_path: Path):
Copy link
Contributor

@eelcovdw eelcovdw Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably need to either move this to the db or lock the file somehow (like https://py-filelock.readthedocs.io/en/latest/ ). The server is using multiple processes so we can have concurrent writes to the token file

with open(file_path, 'r') as f:
file_lines = f.readlines()
if line not in file_lines:
return
file_lines.remove(line)
with open(file_path, 'w') as f:
f.writelines(file_lines)

def invalidate_token(server_settings: ServerSettings, token: str):
write_line_in_file(token, server_settings.banned_tokens_path)
21 changes: 20 additions & 1 deletion syftbox/server/users/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from syftbox.lib.email import send_token_email
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 generate_access_token, generate_email_token, get_access_token, get_user_from_email_token, get_current_user, invalidate_token

router = APIRouter(prefix="/auth", tags=["authentication"])

Expand Down Expand Up @@ -58,6 +58,25 @@ def validate_email_token(
return AccessTokenResponse(access_token=access_token)


@router.post("/invalidate_access_token")
def invalidate_access_token(
token: str = Depends(get_access_token),
server_settings: ServerSettings = Depends(get_server_settings),
) -> str:
"""
Invalidate the access token/

Args:
token (str): Access token. Defaults to Depends(get_access_token).
server_settings (ServerSettings, optional): server settings. Defaults to Depends(get_server_settings).

Returns:
str: message
"""
invalidate_token(server_settings, token)
return "Token invalidation succesful!"


@router.post("/whoami")
def whoami(
email: str = Depends(get_current_user),
Expand Down