Skip to content

Commit

Permalink
feat!: Issue JWT for session pre-authentication
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
MoritzWeber0 committed Oct 28, 2024
1 parent 5cb85b9 commit 7b43e38
Show file tree
Hide file tree
Showing 26 changed files with 620 additions and 93 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ repos:
- capellambse
- typer
- types-lxml
- cryptography
- repo: local
hooks:
- id: pylint
Expand Down
28 changes: 28 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 || \
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions backend/capellacollab/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
},
Expand Down
3 changes: 2 additions & 1 deletion backend/capellacollab/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
71 changes: 71 additions & 0 deletions backend/capellacollab/cli/keys.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
14 changes: 13 additions & 1 deletion backend/capellacollab/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 13 additions & 2 deletions backend/capellacollab/core/database/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}",
},
Expand Down Expand Up @@ -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
),
Expand Down
93 changes: 93 additions & 0 deletions backend/capellacollab/sessions/auth.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions backend/capellacollab/sessions/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from capellacollab.tools import models as tools_models

from . import (
authentication,
guacamole,
http,
interface,
Expand Down Expand Up @@ -31,6 +32,7 @@
"provisioning": provisioning.ProvisionWorkspaceHook(),
"session_preparation": session_preparation.GitRepositoryCloningHook(),
"networking": networking.NetworkingIntegration(),
"authentication": authentication.PreAuthenticationHook(),
}


Expand Down
Loading

0 comments on commit 7b43e38

Please sign in to comment.