Skip to content
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Minimal Python version is now 3.10

- In test, use httpx2 instead of httpx (Deprecation Warning from starlette dependency)

### Added

- Added authentication with OAuth2 (disabled by default). To enable it, configure the `AUTH` field of the configuration file.
- New API version `v2_1_0`:
- New endpoint `POST /api/workflows/discover` to discover external workflows
with a local copy that shadows the external content.
Expand Down
25 changes: 25 additions & 0 deletions doc/tutorials/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Server configuration
tasks, icons are stored. Equivalent to the ``--dir/-d`` command line argument.
- ``EWOKS_EXECUTION`` (dict): Configuration of ewoks handlers. See the `Ewoks events`_ section below.
- ``CELERY`` (dict): Configuration of Celery to allow launching workflows in ewoks workers.
- ``AUTH`` (dict): Authentication of REST requests. See the `Authentication`_ section below.

*Example*:

Expand Down Expand Up @@ -36,6 +37,30 @@ The environment variable ``EWOKSSERVER_SETTINGS`` can be used instead:



Authentication
--------------

Authentication is disabled by default. To enable it, define the ``AUTH`` variable in the configuration file:

.. code-block:: python

AUTH = {
"enabled": True,
"secret_key": "<long random string>",
"token_expire_minutes": 30,
"users": {"alice": "<password hash>"},
}

A password hash can be created with:

.. code-block:: bash

python -c "from pwdlib import PasswordHash; print(PasswordHash.recommended().hash('<password>'))"

Clients get a token with ``POST /api/token`` (form fields ``username`` and ``password``)
and send it in the ``Authorization: Bearer <token>`` header.


Ewoks events
------------

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ dependencies = [
"ewokscore >=1.0.0",
"pydantic-settings",
"packaging",
"pyjwt",
"pwdlib[argon2]",
"python-multipart",
]

[project.urls]
Expand Down Expand Up @@ -87,7 +90,7 @@ ignore = [

[tool.ruff.lint.per-file-ignores]
"src/ewoksserver/tests/*.py" = [
"S101" # allow asserts
"S101", # allow asserts
]

[tool.ruff.lint.isort]
Expand Down
7 changes: 7 additions & 0 deletions src/ewoksserver/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import logging
from pprint import pformat

from fastapi import Depends
from fastapi import FastAPI

from .auth import auth_router
from .auth import get_current_user
from .config import get_app_settings
from .cors import enable_cors
from .lifespan import fastapi_lifespan
from .routes import BACKEND_PREFIX
from .routes import backend
from .routes import execution
from .routes import frontend
Expand Down Expand Up @@ -42,6 +46,7 @@ def create_app() -> FastAPI:
{"name": "workflows", "description": "Ewoks workflows"},
{"name": "icons", "description": "Ewoks workflow icons"},
{"name": "execution", "description": "Ewoks workflow execution"},
{"name": "authentication", "description": "Authentication"},
*(
{"name": tag, "description": f"Ewoks workflows API {tag}"}
for tag in version_tags
Expand Down Expand Up @@ -74,7 +79,9 @@ def create_app() -> FastAPI:
app,
all_routes,
no_older_versions=settings.no_older_versions,
dependencies=[Depends(get_current_user)],
)
app.include_router(auth_router, prefix=BACKEND_PREFIX)

frontend.add_frontend(app) # Needs to come last for some reason

Expand Down
108 changes: 108 additions & 0 deletions src/ewoksserver/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import logging
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import Annotated

import jwt
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import OAuth2PasswordRequestForm
from pwdlib import PasswordHash
from pydantic import BaseModel

from .config import EwoksSettingsType
from .models import EwoksAuthSettings
from .routes import BACKEND_PREFIX

logger = logging.getLogger(__name__)

_password_hash = PasswordHash.recommended()

_DUMMY_HASH = _password_hash.hash("dummy")

ANONYMOUS = "anonymous"

# auto_error=False so that disabled authentication does not require a token
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{BACKEND_PREFIX}/token", auto_error=False
)


class Token(BaseModel):
access_token: str
token_type: str


class User(BaseModel):
username: str


class UnauthorizedHTTPException(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)


def _authenticate_user(auth: EwoksAuthSettings, username: str, password: str) -> bool:
hashed = auth.users.get(username)
if hashed is None:
# Do a dummy check to protect against user enumeration through timing.
_password_hash.verify(password, _DUMMY_HASH)
return False
return _password_hash.verify(password, hashed)


def _create_access_token(auth: EwoksAuthSettings, username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=auth.token_expire_minutes)
return jwt.encode(
{"sub": username, "exp": expire}, auth.secret_key, algorithm=auth.algorithm
)


def get_current_user(
settings: EwoksSettingsType,
token: Annotated[str | None, Depends(oauth2_scheme)],
) -> User:
auth = settings.ewoks_auth
if not auth.enabled:
return User(username=ANONYMOUS)

if token is None:
raise UnauthorizedHTTPException()
try:
payload = jwt.decode(token, auth.secret_key, algorithms=[auth.algorithm])
except jwt.InvalidTokenError:
raise UnauthorizedHTTPException()
username = payload.get("sub")
if username not in auth.users:
raise UnauthorizedHTTPException()
return User(username=username)


auth_router = APIRouter(tags=["authentication"])


@auth_router.post("/token", summary="Get an access token", response_model=Token)
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
settings: EwoksSettingsType,
) -> Token:
auth = settings.ewoks_auth
if not auth.enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Authentication is not enabled",
)
if not _authenticate_user(auth, form_data.username, form_data.password):
raise UnauthorizedHTTPException()
return Token(
access_token=_create_access_token(auth, form_data.username),
token_type="bearer", # noqa: S106 - Ruff thinks this is a password while it is not.
)
3 changes: 3 additions & 0 deletions src/ewoksserver/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def create_ewoks_settings(
ewoks_execution = None
ewoks_discovery = None
celery = None
auth = None
discover_timeout = None
if filename:
spec = importlib.util.spec_from_file_location("ewoksserverconfig", filename)
Expand All @@ -102,6 +103,7 @@ def create_ewoks_settings(
spec.loader.exec_module(mod)
resource_directory = getattr(mod, "RESOURCE_DIRECTORY", resource_directory)
celery = getattr(mod, "CELERY", celery)
auth = getattr(mod, "AUTH", auth)
ewoks_execution = getattr(mod, "EWOKS_EXECUTION", None)
ewoks_discovery = getattr(mod, "EWOKS_DISCOVERY", None)
# DEPRECATED
Expand Down Expand Up @@ -133,6 +135,7 @@ def create_ewoks_settings(
ewoks_execution=ewoks_execution,
ewoks_discovery=ewoks_discovery,
ewoks_scheduling=ewoks_scheduling,
ewoks_auth=auth,
)
return _EWOKS_SETTINGS

Expand Down
26 changes: 25 additions & 1 deletion src/ewoksserver/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -40,6 +41,22 @@ class EwoksJobSettings(BaseModel):
configuration: dict = dict()


class EwoksAuthSettings(BaseModel):
enabled: bool = Field(default=False, title="Require authentication")
secret_key: str | None = Field(default=None, title="Secret used to sign tokens")
algorithm: str = Field(default="HS256", title="Token signing algorithm")
token_expire_minutes: int = Field(default=30, title="Token lifetime (in minutes)")
users: dict[str, str] = Field(
default=dict(), title="Mapping of user names to password hashes"
)

@model_validator(mode="after")
def check_secret_key(self):
if self.enabled and not self.secret_key:
raise ValueError("`secret_key` is required when authentication is enabled")
return self


class EwoksSettings(BaseModel):
configured: bool = Field(
default=False, title="Config or resource directory have been defined"
Expand All @@ -57,9 +74,16 @@ class EwoksSettings(BaseModel):
ewoks_scheduling: EwoksJobSettings = Field(
default=None, title="Ewoks job scheduling settings", validate_default=True
)
ewoks_auth: EwoksAuthSettings = Field(
default=None, title="Ewoks authentication settings", validate_default=True
)

@field_validator(
"ewoks_discovery", "ewoks_execution", "ewoks_scheduling", mode="before"
"ewoks_discovery",
"ewoks_execution",
"ewoks_scheduling",
"ewoks_auth",
mode="before",
)
@classmethod
def set_default_value(cls, input_value):
Expand Down
4 changes: 4 additions & 0 deletions src/ewoksserver/app/routes/backend.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from dataclasses import dataclass
from typing import Callable
from typing import Mapping
from typing import Sequence

from fastapi import APIRouter
from fastapi import Depends
from fastapi import FastAPI
from starlette.types import ASGIApp

Expand Down Expand Up @@ -98,6 +100,7 @@ def add_routes(
app: FastAPI,
all_routes: list[dict[VersionTuple, Route]],
no_older_versions: bool = False,
dependencies: Sequence[Depends] | None = None,
) -> None:
"""Add routes to a fastapi app"""
route_keys = set()
Expand All @@ -116,6 +119,7 @@ def add_routes(
route.router,
prefix=route.prefix,
tags=[route.tag],
dependencies=dependencies,
)
elif isinstance(route.router, Callable):
subapp = route.router()
Expand Down
34 changes: 34 additions & 0 deletions src/ewoksserver/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

from .. import app as newserver
from ..app import config as serverconfig
from ..app.auth import _password_hash
from ..app.backends.binary_backend import _load_url
from ..app.models import EwoksAuthSettings
from ..app.models import EwoksDiscoverySettings
from ..app.models import EwoksExecutionSettings
from ..app.models import EwoksJobSettings
Expand Down Expand Up @@ -278,3 +280,35 @@ def mocked_submit(*args, **kwargs):

submit_local_mock.side_effect = mocked_submit
return arguments


AUTH_SECRET = "test-secret-with-at-least-32-bytes-x" # noqa: S105 - Password can be hardcoded for tests
AUTH_USER = "alice"
AUTH_PASSWORD = "wonderland" # noqa: S105 - Password can be hardcoded for tests


@pytest.fixture
def auth_rest_client(tmp_path):
"""Client to the REST server with authentication enabled."""

app = newserver.create_app()

@lru_cache()
def get_ewoks_settings_for_tests():
return serverconfig.EwoksSettings(
configured=True,
resource_directory=str(tmp_path),
ewoks_discovery=EwoksDiscoverySettings(on_start_up=False),
ewoks_auth=EwoksAuthSettings(
enabled=True,
secret_key=AUTH_SECRET,
users={AUTH_USER: _password_hash.hash(AUTH_PASSWORD)},
),
)

app.dependency_overrides[serverconfig.get_ewoks_settings] = (
get_ewoks_settings_for_tests
)

with TestClient(app) as client:
yield client
Loading
Loading