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 f61f8be
Show file tree
Hide file tree
Showing 15 changed files with 286 additions and 19 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

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()
54 changes: 54 additions & 0 deletions backend/capellacollab/cli/keys.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 7 additions & 1 deletion 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 "https://localhost:443"
"{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/"
),
cookies={
"token": "{CAPELLACOLLAB_SESSION_TOKEN}",
},
Expand Down
89 changes: 89 additions & 0 deletions backend/capellacollab/sessions/auth.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 37 additions & 4 deletions backend/capellacollab/sessions/hooks/http.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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",
)
Loading

0 comments on commit f61f8be

Please sign in to comment.