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..80bbf73b68 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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..6f7d66a219 --- /dev/null +++ b/backend/capellacollab/cli/keys.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +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) + + +@app.command(name="export") +def export_private_key(file: pathlib.Path): + """Export the current private 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." + ) + + sessions_auth.save_private_key_to_disk(private_key, file) diff --git a/backend/capellacollab/core/database/migration.py b/backend/capellacollab/core/database/migration.py index c781289f33..73a5757d0b 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.DEVELOPMENT_MODE + else "https://localhost:443" + "{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/" + ), cookies={ "token": "{CAPELLACOLLAB_SESSION_TOKEN}", }, diff --git a/backend/capellacollab/sessions/auth.py b/backend/capellacollab/sessions/auth.py new file mode 100644 index 0000000000..2e0862c261 --- /dev/null +++ b/backend/capellacollab/sessions/auth.py @@ -0,0 +1,89 @@ +# 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 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( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + +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/http.py b/backend/capellacollab/sessions/hooks/http.py index ceb8422aeb..e4bca06bbd 100644 --- a/backend/capellacollab/sessions/hooks/http.py +++ b/backend/capellacollab/sessions/hooks/http.py @@ -1,22 +1,28 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import datetime import logging +import jwt + from capellacollab.core import models as core_models from capellacollab.tools import models as tools_models +from capellacollab.users import models as users_models +from .. import auth as sessions_auth from .. import models as sessions_models from .. import util as sessions_util from . import interface class HTTPIntegration(interface.HookRegistration): - def session_connection_hook( # type: ignore[override] + def session_connection_hook( # type: ignore[override] # pylint: disable=arguments-renamed self, db_session: sessions_models.DatabaseSession, connection_method: tools_models.ToolSessionConnectionMethod, logger: logging.LoggerAdapter, + user: users_models.DatabaseUser, **kwargs, ) -> interface.SessionConnectionHookResult: if not isinstance( @@ -48,11 +54,38 @@ def session_connection_hook( # type: ignore[override] # Set token for pre-authentication cookies |= { - "ccm_session_token": db_session.environment[ - "CAPELLACOLLAB_SESSION_TOKEN" - ] + "ccm_session_token": self._issue_session_token(user, db_session) } return interface.SessionConnectionHookResult( redirect_url=redirect_url, cookies=cookies, warnings=warnings ) + + 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, + "username": user.name, + "email": user.email, + "role": user.role, + "iat": now, + "exp": expiration, + }, + sessions_auth.PRIVATE_KEY, + algorithm="RS256", + ) diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index ee8dcf56b0..fa3820fda3 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,26 @@ 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"] + ) + except jwt.exceptions.ExpiredSignatureError: + return auth_exceptions.TokenSignatureExpired() + except jwt.exceptions.PyJWTError: + raise auth_exceptions.JWTValidationFailed() + + if decoded_token.get("session_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/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..47508a9c8f 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: {} @@ -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