From 7b43e38aac737a49dd7eed8048cfc0200786a595 Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Fri, 25 Oct 2024 17:36:51 +0200 Subject: [PATCH] feat!: Issue JWT for session pre-authentication Instead of the non-structured session token, issue a JWT containing `session_id`, `user_id`, `user_name` and `user_role`. More claims will be added in the future. During session connection, the backend issues a signed JWT token. The private key is auto-generated in the backend (if it doesn't exist) and can be exchanged via new CLI endpoints. The JWT is validated automatically for all requests to HTTP-based sessions. The JWT can be read from the `ccm_session_token` cookie and can be trusted by sessions. It may be used to extract user or session information in the sessions. The validate_token endpoint doesn't require an active database session anymore, reducing network traffic and improving the response times. This effectively makes sessions faster and improved stability. BREAKING CHANGE: Users with active sessions have to reconnect to their sessions after the update has been rolled out. We recommend to install the update when there are no active sessions. --- .pre-commit-config.yaml | 1 + Makefile | 28 +++++ backend/Makefile | 7 +- backend/capellacollab/__main__.py | 2 + ...add_environment_and_connection_info_to_.py | 5 +- backend/capellacollab/cli/__main__.py | 3 +- backend/capellacollab/cli/keys.py | 71 +++++++++++ backend/capellacollab/core/__init__.py | 14 ++- .../capellacollab/core/database/migration.py | 15 ++- backend/capellacollab/sessions/auth.py | 93 ++++++++++++++ .../capellacollab/sessions/hooks/__init__.py | 2 + .../sessions/hooks/authentication.py | 61 +++++++++ backend/capellacollab/sessions/hooks/http.py | 7 -- backend/capellacollab/sessions/injection.py | 2 +- backend/capellacollab/sessions/routes.py | 33 +++-- backend/pyproject.toml | 1 + backend/tests/cli/test_cli.py | 24 ++++ backend/tests/cli/test_keys.py | 62 ++++++++++ .../tests/sessions/hooks/test_http_hook.py | 33 +++-- .../hooks/test_pre_authentiation_hook.py | 29 +++++ backend/tests/sessions/test_session_auth.py | 116 ++++++++++++++++++ backend/tests/sessions/test_session_routes.py | 59 --------- .../tests/sessions/test_session_sharing.py | 5 + docs/docs/development/index.md | 15 +++ .../backend/backend-data.volume.yaml | 18 +++ .../templates/backend/backend.deployment.yaml | 7 +- 26 files changed, 620 insertions(+), 93 deletions(-) create mode 100644 backend/capellacollab/cli/keys.py create mode 100644 backend/capellacollab/sessions/auth.py create mode 100644 backend/capellacollab/sessions/hooks/authentication.py create mode 100644 backend/tests/cli/test_cli.py create mode 100644 backend/tests/cli/test_keys.py create mode 100644 backend/tests/sessions/hooks/test_pre_authentiation_hook.py create mode 100644 backend/tests/sessions/test_session_auth.py create mode 100644 helm/templates/backend/backend-data.volume.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ede857932..771350fd24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,7 @@ repos: - capellambse - typer - types-lxml + - cryptography - repo: local hooks: - id: pylint diff --git a/Makefile b/Makefile index 4e7ccca989..bdc630b177 100644 --- a/Makefile +++ b/Makefile @@ -237,6 +237,34 @@ dashboard: echo "Please use the following token: $$(kubectl -n default create token dashboard-admin)" kubectl proxy +synchronize-rsa-keys: + export POD_NAME=$$(kubectl get pods \ + --context k3d-$(CLUSTER_NAME) \ + -n $(NAMESPACE) \ + -l id=$(RELEASE)-deployment-backend \ + -o jsonpath="{.items[0].metadata.name}") + + echo "Found Pod $$POD_NAME" + + kubectl exec \ + --context k3d-$(CLUSTER_NAME) \ + -n $(NAMESPACE) \ + --container $(RELEASE)-backend \ + $$POD_NAME \ + -- python -m capellacollab.cli keys export /tmp/private.key + + kubectl cp \ + --context k3d-$(CLUSTER_NAME) \ + -n $(NAMESPACE) \ + --container $(RELEASE)-backend \ + $$POD_NAME:/tmp/private.key \ + /tmp/private.key + + $(MAKE) -C backend import-rsa-key + + rm /tmp/private.key + echo "Please restart the local backend to apply the new RSA key." + openapi: $(MAKE) -C backend openapi $(MAKE) -C frontend openapi diff --git a/backend/Makefile b/backend/Makefile index 1a9e9fc120..7e090e14ba 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -24,7 +24,7 @@ SHELL = /bin/bash export MSYS_NO_PATHCONV=1 export DISABLE_SESSION_TIMEOUT ?= 1 export DISABLE_SESSION_COLLECTOR ?= 1 -export DEVELOPMENT_MODE ?= 1 +export LOCAL_DEVELOPMENT_MODE ?= 1 database: docker start capella-collab-postgres || \ @@ -80,6 +80,11 @@ openapi: --skip-error-responses \ /tmp/openapi.json +import-rsa-key: + $(VENV)/bin/python \ + -m capellacollab.cli keys import \ + /tmp/private.key + dev: database valkey app cleanup: diff --git a/backend/capellacollab/__main__.py b/backend/capellacollab/__main__.py index 094c27449e..2437839ceb 100644 --- a/backend/capellacollab/__main__.py +++ b/backend/capellacollab/__main__.py @@ -20,6 +20,7 @@ from capellacollab.core import logging as core_logging from capellacollab.core.database import engine, migration from capellacollab.routes import router +from capellacollab.sessions import auth as sessions_auth from capellacollab.sessions import idletimeout, operators from . import __version__, metrics @@ -64,6 +65,7 @@ async def shutdown(): startup, idletimeout.terminate_idle_sessions_in_background, pipeline_runs_interface.schedule_refresh_and_trigger_pipeline_jobs, + sessions_auth.initialize_session_pre_authentication, ] app = fastapi.FastAPI( diff --git a/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py b/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py index e83bf3b33c..51ab303d05 100644 --- a/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py +++ b/backend/capellacollab/alembic/versions/7683b08b00ba_add_environment_and_connection_info_to_.py @@ -130,7 +130,10 @@ def get_eclipse_configuration(): "XPRA_SUBPATH": "{CAPELLACOLLAB_SESSIONS_BASE_PATH}", "XPRA_CSP_ORIGIN_HOST": "{CAPELLACOLLAB_ORIGIN_BASE_URL}", }, - "redirect_url": "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/", + "redirect_url": ( + "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}" + "{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" + ), "cookies": { "token": "{CAPELLACOLLAB_SESSION_TOKEN}", }, diff --git a/backend/capellacollab/cli/__main__.py b/backend/capellacollab/cli/__main__.py index 9f9d5040af..997f11c0c5 100644 --- a/backend/capellacollab/cli/__main__.py +++ b/backend/capellacollab/cli/__main__.py @@ -3,11 +3,12 @@ import typer -from . import openapi, ws +from . import keys, openapi, ws app = typer.Typer() app.add_typer(ws.app, name="ws") app.add_typer(openapi.app, name="openapi") +app.add_typer(keys.app, name="keys") if __name__ == "__main__": app() diff --git a/backend/capellacollab/cli/keys.py b/backend/capellacollab/cli/keys.py new file mode 100644 index 0000000000..5505ca7da8 --- /dev/null +++ b/backend/capellacollab/cli/keys.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import enum +import pathlib + +import typer + +from capellacollab.sessions import auth as sessions_auth + +app = typer.Typer( + help="Import and export RSA keys used for session pre-authentication." +) + + +@app.command(name="import") +def import_private_key(file: pathlib.Path): + """Read and load a private key from a file. + + After importing the key, it will be used to sign the JWT session tokens. + The previous key will be discarded. + + Please note that we can only accept private keys which have + been exported using the `export` command of this CLI. + """ + + key = sessions_auth.load_private_key_from_disk(file) + if key is None: + raise typer.BadParameter( + "The provided file does not contain a valid RSA private key." + ) + + sessions_auth.save_private_key_to_disk(key, sessions_auth.PRIVATE_KEY_PATH) + sessions_auth.load_private_key_in_memory(key) + + +class KeyType(str, enum.Enum): + PRIVATE = "private" + PUBLIC = "public" + + +@app.command(name="export") +def export_private_key( + file: pathlib.Path, type: KeyType = typer.Option(default=KeyType.PRIVATE) +): + """Export the current private or public key to a file. + + The private key will be exported in PEM format. + """ + + private_key = sessions_auth.load_private_key_from_disk( + sessions_auth.PRIVATE_KEY_PATH + ) + if private_key is None: + raise typer.BadParameter( + "No private key has been loaded. Use the `import` command to load a key" + " or start the backend once to auto-generate a key." + ) + + if type == KeyType.PRIVATE: + sessions_auth.save_private_key_to_disk(private_key, file) + else: + with open(file, "wb") as f: + f.write( + private_key.public_key().public_bytes( + encoding=sessions_auth.serialization.Encoding.PEM, + format=sessions_auth.serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) diff --git a/backend/capellacollab/core/__init__.py b/backend/capellacollab/core/__init__.py index 33a97c0118..d43c223bfe 100644 --- a/backend/capellacollab/core/__init__.py +++ b/backend/capellacollab/core/__init__.py @@ -3,8 +3,20 @@ import os -DEVELOPMENT_MODE: bool = os.getenv("DEVELOPMENT_MODE", "").lower() in ( +CLUSTER_DEVELOPMENT_MODE: bool = os.getenv( + "CLUSTER_DEVELOPMENT_MODE", "" +).lower() in ( "1", "true", "t", ) + +LOCAL_DEVELOPMENT_MODE: bool = os.getenv( + "LOCAL_DEVELOPMENT_MODE", "" +).lower() in ( + "1", + "true", + "t", +) + +DEVELOPMENT_MODE = LOCAL_DEVELOPMENT_MODE or CLUSTER_DEVELOPMENT_MODE diff --git a/backend/capellacollab/core/database/migration.py b/backend/capellacollab/core/database/migration.py index c781289f33..a87658fc74 100644 --- a/backend/capellacollab/core/database/migration.py +++ b/backend/capellacollab/core/database/migration.py @@ -11,6 +11,7 @@ from alembic import migration from sqlalchemy import orm +from capellacollab import core from capellacollab.config import config from capellacollab.core import database from capellacollab.events import crud as events_crud @@ -157,7 +158,12 @@ def get_eclipse_session_configuration() -> ( "XPRA_SUBPATH": "{CAPELLACOLLAB_SESSIONS_BASE_PATH}", "XPRA_CSP_ORIGIN_HOST": "{CAPELLACOLLAB_ORIGIN_BASE_URL}", }, - redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&sharing=1&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/", + redirect_url=( + "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}" + if not core.LOCAL_DEVELOPMENT_MODE + else "http://localhost:8080" + "{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" + ), cookies={ "token": "{CAPELLACOLLAB_SESSION_TOKEN}", }, @@ -282,7 +288,12 @@ def create_jupyter_tool(db: orm.Session) -> tools_models.DatabaseTool: name="Direct Jupyter connection (Browser)", description="The only available connection method for Jupyter.", ports=tools_models.HTTPPorts(http=8888, metrics=9118), - redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/lab?token={CAPELLACOLLAB_SESSION_TOKEN}", + redirect_url=( + "{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}" + if not core.LOCAL_DEVELOPMENT_MODE + else "http://localhost:8080" + "{CAPELLACOLLAB_SESSIONS_BASE_PATH}/lab?token={CAPELLACOLLAB_SESSION_TOKEN}" + ), sharing=tools_models.ToolSessionSharingConfiguration( enabled=True ), diff --git a/backend/capellacollab/sessions/auth.py b/backend/capellacollab/sessions/auth.py new file mode 100644 index 0000000000..0eee146b50 --- /dev/null +++ b/backend/capellacollab/sessions/auth.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import logging +import pathlib + +import appdirs +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +PRIVATE_KEY: rsa.RSAPrivateKey | None = None +PUBLIC_KEY: rsa.RSAPublicKey | None = None + +PRIVATE_KEY_PATH = ( + pathlib.Path(appdirs.user_data_dir("capellacollab")) / "private_key.pem" +) + +logger = logging.getLogger(__name__) + + +def generate_private_key() -> rsa.RSAPrivateKey: + logger.info( + "Generating a new private key for session pre-authentication..." + ) + return rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + + +def serialize_private_key(key: rsa.RSAPrivateKey) -> bytes: + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def save_private_key_to_disk(key: rsa.RSAPrivateKey, path: pathlib.Path): + logger.info( + "Saving private key for session pre-authentication to %s", path + ) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as f: + f.write( + serialize_private_key(key), + ) + + +def load_private_key_from_disk(path: pathlib.Path) -> rsa.RSAPrivateKey | None: + logger.info( + "Trying to load private key for session pre-authentication from %s", + path, + ) + + if not path.exists(): + logger.info("No private key found at %s", path) + return None + + with open(path, "rb") as f: + key = serialization.load_pem_private_key( + f.read(), + password=None, + ) + + if not isinstance(key, rsa.RSAPrivateKey): + logger.exception("The loaded private key is not an RSA key.") + return None + + logger.info( + "Successfully loaded private key for session pre-authentication from %s", + path, + ) + + return key + + +def load_private_key_in_memory(key: rsa.RSAPrivateKey): + global PRIVATE_KEY + global PUBLIC_KEY + + PRIVATE_KEY = key + PUBLIC_KEY = PRIVATE_KEY.public_key() + + +def initialize_session_pre_authentication(): + private_key = load_private_key_from_disk(PRIVATE_KEY_PATH) + + if not private_key: + private_key = generate_private_key() + save_private_key_to_disk(private_key, PRIVATE_KEY_PATH) + + load_private_key_in_memory(private_key) diff --git a/backend/capellacollab/sessions/hooks/__init__.py b/backend/capellacollab/sessions/hooks/__init__.py index 6032bbfce7..09d7de7904 100644 --- a/backend/capellacollab/sessions/hooks/__init__.py +++ b/backend/capellacollab/sessions/hooks/__init__.py @@ -4,6 +4,7 @@ from capellacollab.tools import models as tools_models from . import ( + authentication, guacamole, http, interface, @@ -31,6 +32,7 @@ "provisioning": provisioning.ProvisionWorkspaceHook(), "session_preparation": session_preparation.GitRepositoryCloningHook(), "networking": networking.NetworkingIntegration(), + "authentication": authentication.PreAuthenticationHook(), } diff --git a/backend/capellacollab/sessions/hooks/authentication.py b/backend/capellacollab/sessions/hooks/authentication.py new file mode 100644 index 0000000000..16b0aaf2f3 --- /dev/null +++ b/backend/capellacollab/sessions/hooks/authentication.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime + +import jwt + +from capellacollab.users import models as users_models + +from .. import auth as sessions_auth +from .. import models as sessions_models +from . import interface + + +class PreAuthenticationHook(interface.HookRegistration): + def session_connection_hook( # type: ignore[override] + self, + db_session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + **kwargs, + ) -> interface.SessionConnectionHookResult: + """Issue pre-authentication tokens for sessions""" + + return interface.SessionConnectionHookResult( + cookies={ + "ccm_session_token": self._issue_session_token( + user, db_session + ) + } + ) + + def _issue_session_token( + self, + user: users_models.DatabaseUser, + db_session: sessions_models.DatabaseSession, + ): + assert sessions_auth.PRIVATE_KEY + + now = datetime.datetime.now(datetime.UTC) + + # The session token expires after 1 day. + # In the rare case that a user works for more than 1 day + # without a break, the user has to re-connect to the session. + # Each connection attempt issues a new session token. + expiration = now + datetime.timedelta(days=1) + + return jwt.encode( + { + "session": {"id": db_session.id}, + "user": { + "id": user.id, + "name": user.name, + "email": user.email, + "role": user.role, + }, + "iat": now, + "exp": expiration, + }, + sessions_auth.PRIVATE_KEY, + algorithm="RS256", + ) diff --git a/backend/capellacollab/sessions/hooks/http.py b/backend/capellacollab/sessions/hooks/http.py index ceb8422aeb..aae9953021 100644 --- a/backend/capellacollab/sessions/hooks/http.py +++ b/backend/capellacollab/sessions/hooks/http.py @@ -46,13 +46,6 @@ def session_connection_hook( # type: ignore[override] logger, db_session.environment, connection_method.cookies ) - # Set token for pre-authentication - cookies |= { - "ccm_session_token": db_session.environment[ - "CAPELLACOLLAB_SESSION_TOKEN" - ] - } - return interface.SessionConnectionHookResult( redirect_url=redirect_url, cookies=cookies, warnings=warnings ) diff --git a/backend/capellacollab/sessions/injection.py b/backend/capellacollab/sessions/injection.py index 7338dcd787..4526a337a5 100644 --- a/backend/capellacollab/sessions/injection.py +++ b/backend/capellacollab/sessions/injection.py @@ -16,7 +16,7 @@ def get_last_seen(sid: str) -> str: """Return project session last seen activity""" - if core.DEVELOPMENT_MODE: + if core.LOCAL_DEVELOPMENT_MODE: return "Disabled in development mode" url = f"{config.prometheus.url}/api/v1/query?query=idletime_minutes" diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index ee8dcf56b0..7ee7908701 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -3,11 +3,11 @@ import datetime -import hmac import logging import typing as t import fastapi +import jwt from fastapi import status from sqlalchemy import orm @@ -15,6 +15,7 @@ from capellacollab.core import logging as log from capellacollab.core import models as core_models from capellacollab.core import responses +from capellacollab.core.authentication import exceptions as auth_exceptions from capellacollab.core.authentication import injectables as auth_injectables from capellacollab.sessions import hooks from capellacollab.sessions.files import routes as files_routes @@ -26,7 +27,7 @@ from capellacollab.users import injectables as users_injectables from capellacollab.users import models as users_models -from . import crud, exceptions, injectables, models, operators, util +from . import auth, crud, exceptions, injectables, models, operators, util from .operators import k8s from .operators import models as operators_models @@ -407,21 +408,29 @@ def get_session_connection_information( def validate_session_token( session_id: str, ccm_session_token: t.Annotated[str | None, fastapi.Cookie()] = None, - db: orm.Session = fastapi.Depends(database.get_db), ): """Validate that the passed session token is valid for the given session.""" - session = crud.get_session_by_id(db, session_id) - - if not session or not ccm_session_token: + if not ccm_session_token: return fastapi.Response(status_code=status.HTTP_401_UNAUTHORIZED) - if hmac.compare_digest( - ccm_session_token, - session.environment["CAPELLACOLLAB_SESSION_TOKEN"], - ): - return fastapi.Response(status_code=status.HTTP_204_NO_CONTENT) + assert auth.PUBLIC_KEY + + try: + decoded_token = jwt.decode( + jwt=ccm_session_token, + key=auth.PUBLIC_KEY, + algorithms=["RS256"], + options={"require": ["exp", "iat"]}, + ) + except jwt.exceptions.ExpiredSignatureError: + return auth_exceptions.TokenSignatureExpired() + except jwt.exceptions.PyJWTError: + raise auth_exceptions.JWTValidationFailed() + + if decoded_token.get("session", {}).get("id") != session_id: + return fastapi.Response(status_code=status.HTTP_403_FORBIDDEN) - return fastapi.Response(status_code=status.HTTP_403_FORBIDDEN) + return fastapi.Response(status_code=status.HTTP_204_NO_CONTENT) @router.delete( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 47bd897570..2b067a871a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "typer", "lxml", "valkey[libvalkey]", + "cryptography", ] [project.urls] diff --git a/backend/tests/cli/test_cli.py b/backend/tests/cli/test_cli.py new file mode 100644 index 0000000000..959ea7b3ce --- /dev/null +++ b/backend/tests/cli/test_cli.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from typer import testing + +from capellacollab.cli import __main__ as cli + + +@pytest.fixture(name="cli_runner") +def fixture_cli_runner() -> testing.CliRunner: + return testing.CliRunner() + + +def test_cli_help(cli_runner: testing.CliRunner): + + result = cli_runner.invoke(cli.app, ["--help"]) + assert result.exit_code == 0 + + assert "Usage:" in result.output + + assert "keys" in result.output + assert "openapi" in result.output + assert "ws" in result.output diff --git a/backend/tests/cli/test_keys.py b/backend/tests/cli/test_keys.py new file mode 100644 index 0000000000..11d0be5a31 --- /dev/null +++ b/backend/tests/cli/test_keys.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pathlib + +import pytest +from cryptography.hazmat.primitives import serialization + +from capellacollab.cli import keys +from capellacollab.sessions import auth as sessions_auth + + +@pytest.fixture(name="private_key_path") +def fixture_private_key_path( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> pathlib.Path: + path = tmp_path / "private_key.pem" + monkeypatch.setattr(sessions_auth, "PRIVATE_KEY_PATH", path) + return path + + +def test_key_import(tmp_path: pathlib.Path, private_key_path: pathlib.Path): + private_key = sessions_auth.generate_private_key() + path_to_import_from = tmp_path / "private_key_to_import.pem" + sessions_auth.save_private_key_to_disk( + private_key, tmp_path / path_to_import_from + ) + + keys.import_private_key(tmp_path / path_to_import_from) + + loaded_key = sessions_auth.load_private_key_from_disk(private_key_path) + assert loaded_key + + assert sessions_auth.serialize_private_key( + loaded_key + ) == sessions_auth.serialize_private_key(private_key) + + +def test_private_key_export(private_key_path: pathlib.Path): + private_key = sessions_auth.generate_private_key() + sessions_auth.save_private_key_to_disk(private_key, private_key_path) + + path_to_export_to = private_key_path.parent / "exported_private_key.pem" + keys.export_private_key(path_to_export_to, keys.KeyType.PRIVATE) + loaded_key = sessions_auth.load_private_key_from_disk(path_to_export_to) + assert loaded_key + assert sessions_auth.serialize_private_key( + loaded_key + ) == sessions_auth.serialize_private_key(private_key) + + +def test_public_key_export(private_key_path: pathlib.Path): + private_key = sessions_auth.generate_private_key() + sessions_auth.save_private_key_to_disk(private_key, private_key_path) + + path_to_export_to = private_key_path.parent / "exported_key.pub" + keys.export_private_key(path_to_export_to, keys.KeyType.PUBLIC) + with open(path_to_export_to, "rb") as f: + public_key = serialization.load_pem_public_key( + f.read(), + ) + assert public_key == private_key.public_key() diff --git a/backend/tests/sessions/hooks/test_http_hook.py b/backend/tests/sessions/hooks/test_http_hook.py index 3fe2026910..ea69bd2c07 100644 --- a/backend/tests/sessions/hooks/test_http_hook.py +++ b/backend/tests/sessions/hooks/test_http_hook.py @@ -3,13 +3,21 @@ import logging +import pytest + +from capellacollab.sessions import auth as sessions_auth from capellacollab.sessions import models as sessions_models from capellacollab.sessions.hooks import http from capellacollab.sessions.hooks import interface as sessions_hooks_interface from capellacollab.tools import models as tools_models +from capellacollab.users import models as users_models -def test_http_hook(session: sessions_models.DatabaseSession): +def test_http_hook( + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + logger: logging.LoggerAdapter, +): session.environment = { "TEST": "test", "CAPELLACOLLAB_SESSION_TOKEN": "test", @@ -20,25 +28,35 @@ def test_http_hook(session: sessions_models.DatabaseSession): ) result = http.HTTPIntegration().session_connection_hook( db_session=session, + user=user, connection_method=connection_method, - logger=logging.getLogger(), + logger=logger, ) - assert result["cookies"] == {"test": "test", "ccm_session_token": "test"} + assert result["cookies"]["test"] == "test" assert result["redirect_url"] == "http://localhost:8000/test" assert not result["warnings"] -def test_skip_http_hook_if_guacamole(session: sessions_models.DatabaseSession): +def test_skip_http_hook_if_guacamole( + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + logger: logging.LoggerAdapter, +): result = http.HTTPIntegration().session_connection_hook( db_session=session, connection_method=tools_models.GuacamoleConnectionMethod(), - logger=logging.getLogger(), + user=user, + logger=logger, ) assert result == sessions_hooks_interface.SessionConnectionHookResult() -def test_fail_derive_redirect_url(session: sessions_models.DatabaseSession): +def test_fail_derive_redirect_url( + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + logger: logging.LoggerAdapter, +): session.environment = {"TEST": "test"} connection_method = tools_models.HTTPConnectionMethod( redirect_url="http://localhost:8000/{TEST2}" @@ -46,7 +64,8 @@ def test_fail_derive_redirect_url(session: sessions_models.DatabaseSession): result = http.HTTPIntegration().session_connection_hook( db_session=session, connection_method=connection_method, - logger=logging.getLogger(), + user=user, + logger=logger, ) assert len(result["warnings"]) == 1 diff --git a/backend/tests/sessions/hooks/test_pre_authentiation_hook.py b/backend/tests/sessions/hooks/test_pre_authentiation_hook.py new file mode 100644 index 0000000000..50ef300b58 --- /dev/null +++ b/backend/tests/sessions/hooks/test_pre_authentiation_hook.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import pytest + +from capellacollab.sessions import auth as sessions_auth +from capellacollab.sessions import models as sessions_models +from capellacollab.sessions.hooks import authentication +from capellacollab.users import models as users_models + + +def test_pre_authentication_hook( + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, + logger: logging.LoggerAdapter, + monkeypatch: pytest.MonkeyPatch, +): + private_key = sessions_auth.generate_private_key() + monkeypatch.setattr(sessions_auth, "PRIVATE_KEY", private_key) + + result = authentication.PreAuthenticationHook().session_connection_hook( + db_session=session, + user=user, + logger=logger, + ) + + assert "ccm_session_token" in result["cookies"] diff --git a/backend/tests/sessions/test_session_auth.py b/backend/tests/sessions/test_session_auth.py new file mode 100644 index 0000000000..884e1de35b --- /dev/null +++ b/backend/tests/sessions/test_session_auth.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from fastapi import testclient + +from capellacollab.sessions import auth as sessions_auth +from capellacollab.sessions import models as sessions_models +from capellacollab.sessions.hooks import ( + authentication as sessions_authentication_hook, +) +from capellacollab.users import models as users_models + + +@pytest.fixture(name="session_token") +def fixture_session_token( + monkeypatch: pytest.MonkeyPatch, + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, +) -> str: + private_key = sessions_auth.generate_private_key() + monkeypatch.setattr(sessions_auth, "PRIVATE_KEY", private_key) + monkeypatch.setattr(sessions_auth, "PUBLIC_KEY", private_key.public_key()) + + return sessions_authentication_hook.PreAuthenticationHook()._issue_session_token( + user=user, db_session=session + ) + + +def test_validate_session_token_with_invalid_session( + client: testclient.TestClient, + session_token: str, +): + """Test that it's not possible to see if a session is running with an invalid token""" + + response = client.post( + "/api/v1/sessions/xwmuiapqqnwxlmcpchsifnamj/tokens/validate", + cookies={ + "ccm_session_token": session_token, + }, + ) + + assert response.status_code == 403 + + +def test_validate_session_token_without_token_cookie( + client: testclient.TestClient, + session: sessions_models.DatabaseSession, +): + """Test that a request without a cookie is declined during validation""" + + response = client.post(f"/api/v1/sessions/{session.id}/tokens/validate") + + assert response.status_code == 401 + + +@pytest.mark.usefixtures("session_token") +def test_validate_session_token_with_invalid_token( + client: testclient.TestClient, + session: sessions_models.DatabaseSession, +): + """Test that an invalid token is declined during validation""" + + response = client.post( + f"/api/v1/sessions/{session.id}/tokens/validate", + cookies={"ccm_session_token": "invalid"}, + ) + + assert response.status_code == 401 + + +def test_validate_session_token_with_valid_token( + client: testclient.TestClient, + session: sessions_models.DatabaseSession, + session_token: str, +): + """Test that a valid session tokens also validates correctly""" + + response = client.post( + f"/api/v1/sessions/{session.id}/tokens/validate", + cookies={ + "ccm_session_token": session_token, + }, + ) + + assert response.is_success + + +def test_validate_session_token_with_invalid_signature( + client: testclient.TestClient, + monkeypatch: pytest.MonkeyPatch, + session: sessions_models.DatabaseSession, + user: users_models.DatabaseUser, +): + """Test that the token validation fails if the signature is invalid""" + + private_key = sessions_auth.generate_private_key() + monkeypatch.setattr(sessions_auth, "PRIVATE_KEY", private_key) + + another_private_key = sessions_auth.generate_private_key() + monkeypatch.setattr( + sessions_auth, "PUBLIC_KEY", another_private_key.public_key() + ) + + token = sessions_authentication_hook.PreAuthenticationHook()._issue_session_token( + user=user, db_session=session + ) + + response = client.post( + f"/api/v1/sessions/{session.id}/tokens/validate", + cookies={ + "ccm_session_token": token, + }, + ) + + assert response.status_code == 401 diff --git a/backend/tests/sessions/test_session_routes.py b/backend/tests/sessions/test_session_routes.py index e9bca99ebf..cfb28a3899 100644 --- a/backend/tests/sessions/test_session_routes.py +++ b/backend/tests/sessions/test_session_routes.py @@ -177,65 +177,6 @@ def test_create_session_without_provisioning( assert kubernetes.sessions -@pytest.mark.usefixtures("executor_name") -def test_validate_session_token_with_invalid_session( - client: testclient.TestClient, -): - """Test that it's not possible to see if a session is running with an invalid token""" - - response = client.post( - "/api/v1/sessions/unknownsession/tokens/validate", - cookies={ - "ccm_session_token": "invalid", - }, - ) - - assert response.status_code == 401 - - -def test_validate_session_token_without_token_cookie( - client: testclient.TestClient, - session: sessions_models.DatabaseSession, -): - """Test that a request without a cookie is declined during validation""" - - response = client.post(f"/api/v1/sessions/{session.id}/tokens/validate") - - assert response.status_code == 401 - - -def test_validate_session_token_with_invalid_token( - client: testclient.TestClient, - session: sessions_models.DatabaseSession, -): - """Test that an invalid token is declined during validation""" - - response = client.post( - f"/api/v1/sessions/{session.id}/tokens/validate", - cookies={"ccm_session_token": "invalid"}, - ) - - assert response.status_code == 403 - - -def test_validate_session_token_with_valid_token( - client: testclient.TestClient, - session: sessions_models.DatabaseSession, -): - """Test that a valid session tokens also validates correctly""" - - response = client.post( - f"/api/v1/sessions/{session.id}/tokens/validate", - cookies={ - "ccm_session_token": session.environment[ - "CAPELLACOLLAB_SESSION_TOKEN" - ], - }, - ) - - assert response.is_success - - def test_get_all_sessions( db: orm.Session, client: testclient.TestClient, diff --git a/backend/tests/sessions/test_session_sharing.py b/backend/tests/sessions/test_session_sharing.py index e71be0c415..46121ba933 100644 --- a/backend/tests/sessions/test_session_sharing.py +++ b/backend/tests/sessions/test_session_sharing.py @@ -8,6 +8,7 @@ from sqlalchemy import orm from capellacollab.__main__ import app +from capellacollab.sessions import auth as sessions_auth from capellacollab.sessions import crud as sessions_crud from capellacollab.sessions import models as sessions_models from capellacollab.tools import models as tools_models @@ -192,7 +193,11 @@ def test_share_session( def test_connect_to_shared_session( shared_session: sessions_models.DatabaseSession, client: testclient.TestClient, + monkeypatch: pytest.MonkeyPatch, ): + private_key = sessions_auth.generate_private_key() + monkeypatch.setattr(sessions_auth, "PRIVATE_KEY", private_key) + response = client.get( f"/api/v1/sessions/{shared_session.id}/connection", ) diff --git a/docs/docs/development/index.md b/docs/docs/development/index.md index 112b81e0ca..6f2dc76556 100644 --- a/docs/docs/development/index.md +++ b/docs/docs/development/index.md @@ -77,6 +77,21 @@ If everything went well, the frontend and backend should be running now: - [Documentation](http://localhost:8081) - [Storybook](http://localhost:6006) +### Spawn and Access Sessions in the Cluster + +You can also spawn sessions in the development environment, but it requires a +running +[local k3d deployment](https://github.com/DSD-DBS/capella-collab-manager#running-locally-with-k3d). + +Sessions are secured with pre-authentication. If you use the same private key +in the cluster and locally, the token issued in the development environment +will also be accepted in the development k3d cluster. To synchronize the keys, +run the following command: + +```zsh +make synchronize-rsa-keys +``` + ## General Notes ### REST APIs diff --git a/helm/templates/backend/backend-data.volume.yaml b/helm/templates/backend/backend-data.volume.yaml new file mode 100644 index 0000000000..9865322d4f --- /dev/null +++ b/helm/templates/backend/backend-data.volume.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ .Release.Name }}-backend-data + labels: + id: {{ .Release.Name }}-pvc-backend-data + annotations: + "helm.sh/resource-policy": keep +spec: + accessModes: + - {{ .Values.backend.storageAccessMode }} + resources: + requests: + storage: 50Mi + storageClassName: {{ .Values.cluster.pvc.storageClassName }} diff --git a/helm/templates/backend/backend.deployment.yaml b/helm/templates/backend/backend.deployment.yaml index 01894d89dc..036a9ec859 100644 --- a/helm/templates/backend/backend.deployment.yaml +++ b/helm/templates/backend/backend.deployment.yaml @@ -28,6 +28,9 @@ spec: - name: config configMap: name: {{ .Release.Name }}-backend + - name: data + persistentVolumeClaim: + claimName: {{ .Release.Name }}-backend-data {{ if .Values.loki.enabled }} - name: logs emptyDir: {} @@ -59,7 +62,7 @@ spec: - name: OAUTHLIB_INSECURE_TRANSPORT value: "1" {{ end }} - - name: DEVELOPMENT_MODE + - name: CLUSTER_DEVELOPMENT_MODE value: "{{ .Values.development }}" - name: no_proxy_additional value: "{{ .Values.proxy.no_proxy }}" @@ -96,6 +99,8 @@ spec: - name: config mountPath: /etc/capellacollab readOnly: true + - name: data + mountPath: /.local/share/capellacollab {{ if .Values.loki.enabled }} - name: logs mountPath: /var/log/backend