From 2a933609946fcb231b5170bcb1ad04afbc9b5736 Mon Sep 17 00:00:00 2001 From: Loic Huder Date: Thu, 8 Oct 2026 13:47:05 +0200 Subject: [PATCH] Add basic OAuth2 token-based authentication (disabled by default) --- CHANGELOG.md | 2 +- doc/tutorials/configuration.rst | 25 ++++++ pyproject.toml | 5 +- src/ewoksserver/app/__init__.py | 7 ++ src/ewoksserver/app/auth.py | 108 ++++++++++++++++++++++++++ src/ewoksserver/app/config.py | 3 + src/ewoksserver/app/models.py | 26 ++++++- src/ewoksserver/app/routes/backend.py | 4 + src/ewoksserver/tests/conftest.py | 34 ++++++++ src/ewoksserver/tests/test_auth.py | 64 +++++++++++++++ 10 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 src/ewoksserver/app/auth.py create mode 100644 src/ewoksserver/tests/test_auth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 243ec52..71e15b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/doc/tutorials/configuration.rst b/doc/tutorials/configuration.rst index 9e4ea36..c43355a 100644 --- a/doc/tutorials/configuration.rst +++ b/doc/tutorials/configuration.rst @@ -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*: @@ -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": "", + "token_expire_minutes": 30, + "users": {"alice": ""}, + } + +A password hash can be created with: + +.. code-block:: bash + + python -c "from pwdlib import PasswordHash; print(PasswordHash.recommended().hash(''))" + +Clients get a token with ``POST /api/token`` (form fields ``username`` and ``password``) +and send it in the ``Authorization: Bearer `` header. + + Ewoks events ------------ diff --git a/pyproject.toml b/pyproject.toml index c18aafe..056c767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "ewokscore >=1.0.0", "pydantic-settings", "packaging", + "pyjwt", + "pwdlib[argon2]", + "python-multipart", ] [project.urls] @@ -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] diff --git a/src/ewoksserver/app/__init__.py b/src/ewoksserver/app/__init__.py index 4f576c8..0d6e086 100644 --- a/src/ewoksserver/app/__init__.py +++ b/src/ewoksserver/app/__init__.py @@ -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 @@ -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 @@ -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 diff --git a/src/ewoksserver/app/auth.py b/src/ewoksserver/app/auth.py new file mode 100644 index 0000000..a7d58cd --- /dev/null +++ b/src/ewoksserver/app/auth.py @@ -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. + ) diff --git a/src/ewoksserver/app/config.py b/src/ewoksserver/app/config.py index 29f9530..63aea60 100644 --- a/src/ewoksserver/app/config.py +++ b/src/ewoksserver/app/config.py @@ -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) @@ -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 @@ -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 diff --git a/src/ewoksserver/app/models.py b/src/ewoksserver/app/models.py index aab80e4..17c03fe 100644 --- a/src/ewoksserver/app/models.py +++ b/src/ewoksserver/app/models.py @@ -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__) @@ -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" @@ -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): diff --git a/src/ewoksserver/app/routes/backend.py b/src/ewoksserver/app/routes/backend.py index 20f8ef6..27e4b4f 100644 --- a/src/ewoksserver/app/routes/backend.py +++ b/src/ewoksserver/app/routes/backend.py @@ -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 @@ -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() @@ -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() diff --git a/src/ewoksserver/tests/conftest.py b/src/ewoksserver/tests/conftest.py index abc5b37..a780963 100644 --- a/src/ewoksserver/tests/conftest.py +++ b/src/ewoksserver/tests/conftest.py @@ -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 @@ -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 diff --git a/src/ewoksserver/tests/test_auth.py b/src/ewoksserver/tests/test_auth.py new file mode 100644 index 0000000..b15e8ec --- /dev/null +++ b/src/ewoksserver/tests/test_auth.py @@ -0,0 +1,64 @@ +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +import jwt + +from .conftest import AUTH_PASSWORD +from .conftest import AUTH_SECRET +from .conftest import AUTH_USER + + +def test_auth_disabled_by_default(rest_client): + assert rest_client.get("/api/workflows").status_code == 200 + response = rest_client.post( + "/api/token", data={"username": AUTH_USER, "password": AUTH_PASSWORD} + ) + assert response.status_code == 404 + + +def test_no_token(auth_rest_client): + response = auth_rest_client.get("/api/workflows") + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + + +def test_bad_credentials(auth_rest_client): + response = auth_rest_client.post( + "/api/token", data={"username": "bob", "password": "wrong"} + ) + assert response.status_code == 401 + + +def test_valid_token(auth_rest_client): + response = auth_rest_client.post( + "/api/token", data={"username": AUTH_USER, "password": AUTH_PASSWORD} + ) + assert response.status_code == 200 + token = response.json()["access_token"] + + response = auth_rest_client.get( + "/api/workflows", headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 200 + + +def test_expired_token(auth_rest_client): + expired = datetime.now(timezone.utc) - timedelta(minutes=1) + token = jwt.encode( + {"sub": AUTH_USER, "exp": expired}, AUTH_SECRET, algorithm="HS256" + ) + response = auth_rest_client.get( + "/api/workflows", headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 401 + + +def test_token_wrong_signature(auth_rest_client): + token = jwt.encode( + {"sub": AUTH_USER}, "other-secret-with-at-least-32-bytes", algorithm="HS256" + ) + response = auth_rest_client.get( + "/api/workflows", headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 401