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 25, 2024
1 parent 5cb85b9 commit 18231ce
Show file tree
Hide file tree
Showing 23 changed files with 580 additions and 89 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
5 changes: 5 additions & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
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

Check warning on line 6 in backend/capellacollab/cli/__main__.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/cli/__main__.py#L6

Added line #L6 was not covered by tests

app = typer.Typer()
app.add_typer(ws.app, name="ws")
app.add_typer(openapi.app, name="openapi")
app.add_typer(keys.app, name="keys")

Check warning on line 11 in backend/capellacollab/cli/__main__.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/cli/__main__.py#L11

Added line #L11 was not covered by tests

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(

Check warning on line 31 in backend/capellacollab/cli/keys.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/cli/keys.py#L31

Added line #L31 was not covered by tests
"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(

Check warning on line 57 in backend/capellacollab/cli/keys.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/cli/keys.py#L57

Added line #L57 was not covered by tests
"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,
)
)
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.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.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

Check warning on line 58 in backend/capellacollab/sessions/auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/sessions/auth.py#L57-L58

Added lines #L57 - L58 were not covered by tests

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

Check warning on line 68 in backend/capellacollab/sessions/auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/sessions/auth.py#L67-L68

Added lines #L67 - L68 were not covered by tests

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)

Check warning on line 87 in backend/capellacollab/sessions/auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/sessions/auth.py#L87

Added line #L87 was not covered by tests

if not private_key:
private_key = generate_private_key()
save_private_key_to_disk(private_key, PRIVATE_KEY_PATH)

Check warning on line 91 in backend/capellacollab/sessions/auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/sessions/auth.py#L90-L91

Added lines #L90 - L91 were not covered by tests

load_private_key_in_memory(private_key)

Check warning on line 93 in backend/capellacollab/sessions/auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/sessions/auth.py#L93

Added line #L93 was not covered by tests
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
61 changes: 61 additions & 0 deletions backend/capellacollab/sessions/hooks/authentication.py
Original file line number Diff line number Diff line change
@@ -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",
)
7 changes: 0 additions & 7 deletions backend/capellacollab/sessions/hooks/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading

0 comments on commit 18231ce

Please sign in to comment.