Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Replace bearer with cookie-based authentication #1587

Merged
merged 8 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*.{sh,py} text eol=lf
.git_archival.txt export-subst
*.mp4 filter=lfs diff=lfs merge=lfs -text
frontend/src/app/openapi/** linguist-generated=true
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ helm-deploy:
--set docker.registry.sessions=$(CAPELLACOLLAB_SESSIONS_REGISTRY) \
--set docker.tag=$(DOCKER_TAG) \
--set mocks.oauth=True \
--set backend.authentication.claimMapping.username=sub \
--set backend.authentication.endpoints.authorization=https://localhost/default/authorize \
--set development=$(DEVELOPMENT_MODE) \
--set cluster.ingressClassName=traefik \
--set cluster.ingressNamespace=kube-system \
Expand Down
13 changes: 10 additions & 3 deletions backend/capellacollab/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import capellacollab.projects.toolmodels.backups.runs.interface as pipeline_runs_interface
import capellacollab.sessions.metrics as sessions_metrics
import capellacollab.settings.modelsources.t4c.metrics as t4c_metrics
from capellacollab import core

# This import statement is required and should not be removed! (Alembic will not work otherwise)
from capellacollab.config import config
Expand All @@ -40,6 +41,13 @@
handlers=[stream_handler, timed_rotating_file_handler],
)

ALLOW_ORIGINS = (
[f"{config.general.scheme}//{config.general.host}:{config.general.port}"]
+ ["http://localhost:4200"]
if core.DEVELOPMENT_MODE
else []
)


async def startup():
migration.migrate_db(engine, config.database.url)
Expand All @@ -49,7 +57,6 @@ async def startup():
operators.get_operator()

logging.getLogger("uvicorn.access").disabled = True
logging.getLogger("requests_oauthlib.oauth2_session").setLevel("INFO")
logging.getLogger("kubernetes.client.rest").setLevel("INFO")


Expand All @@ -71,7 +78,7 @@ async def shutdown():
middleware=[
middleware.Middleware(
cors.CORSMiddleware,
allow_origins=["*"],
allow_origins=ALLOW_ORIGINS,
allow_credentials=True,
allow_methods=["POST", "GET", "OPTIONS", "DELETE", "PUT", "PATCH"],
allow_headers=["*"],
Expand Down Expand Up @@ -110,7 +117,7 @@ async def handle_exceptions(request: fastapi.Request, exc: Exception):
"""
cors_middleware = cors.CORSMiddleware(
app=app,
allow_origins=["*"],
allow_origins=ALLOW_ORIGINS,
allow_credentials=True,
allow_methods=["POST", "GET", "OPTIONS", "DELETE", "PUT", "PATCH"],
allow_headers=["*"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

"""Add IdP identifier and email columns

Revision ID: 028c72ddfd20
Revises: a1e59021e0d0
Create Date: 2024-07-22 14:49:47.575605

"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "028c72ddfd20"
down_revision = "a1e59021e0d0"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"users", sa.Column("idp_identifier", sa.String(), nullable=True)
)

t_users = sa.Table("users", sa.MetaData(), autoload_with=op.get_bind())

users = op.get_bind().execute(sa.select(t_users))
for user in users:
op.get_bind().execute(
sa.update(t_users)
.where(t_users.c.id == user.id)
.values(idp_identifier=user.name)
)

op.alter_column("users", "idp_identifier", nullable=False)

op.add_column("users", sa.Column("email", sa.String(), nullable=True))
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(
op.f("ix_users_idp_identifier"),
"users",
["idp_identifier"],
unique=True,
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

"""Commit message
"""Add column for Jupyter token

Revision ID: f3d2dedd7906
Revises: 4df9c82766e2
Expand Down
70 changes: 15 additions & 55 deletions backend/capellacollab/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,27 +225,20 @@ class AuthOauthClientConfig(BaseConfig):
id: str = pydantic.Field(
default="default", description="The authentication provider client ID."
)
secret: str | None = pydantic.Field(
default=None, description="The authentication provider client secret."
secret: str = pydantic.Field(
default="", description="The authentication provider client secret."
)


class AuthOauthEndpointsConfig(BaseConfig):
token_issuance: str | None = pydantic.Field(
default=None,
description=(
"The URL of the token issuance endpoint. "
"If not set, the URL is read from the well-known endpoint."
),
)
authorization: str | None = pydantic.Field(
default=None,
description=(
"The URL of the authorization endpoint. "
"If not set, the URL is read from the well-known endpoint."
),
)
well_known: str | None = pydantic.Field(
well_known: str = pydantic.Field(
dominik003 marked this conversation as resolved.
Show resolved Hide resolved
default="http://localhost:8083/default/.well-known/openid-configuration",
description="The URL of the OpenID Connect discovery document.",
examples=[
Expand All @@ -254,12 +247,19 @@ class AuthOauthEndpointsConfig(BaseConfig):
)


class AuthOauthConfig(BaseConfig):
class ClaimMappingConfig(BaseConfig):
identifier: str = pydantic.Field(default="sub")
username: str = pydantic.Field(default="sub")
email: str | None = pydantic.Field(default="email")


class AuthenticationConfig(BaseConfig):
endpoints: AuthOauthEndpointsConfig = AuthOauthEndpointsConfig()
audience: str = pydantic.Field(default="default")
scopes: list[str] | None = pydantic.Field(
default=["openid"],
description="List of scopes that application neeeds to access the required attributes.",
mapping: ClaimMappingConfig = ClaimMappingConfig()
scopes: list[str] = pydantic.Field(
default=["openid", "profile", "offline_access"],
description="List of scopes that the application needs to access the required attributes.",
)
client: AuthOauthClientConfig = AuthOauthClientConfig()
redirect_uri: str = pydantic.Field(
Expand All @@ -270,44 +270,6 @@ class AuthOauthConfig(BaseConfig):
)


class JWTConfig(BaseConfig):
username_claim: str = pydantic.Field(
default="sub",
description="Specifies the key in the JWT payload where the username is stored.",
examples=["sub", "aud", "preferred_username"],
)


class AzureClientConfig(BaseConfig):
id: str
secret: str | None = None


class AzureConfig(BaseConfig):
authorization_endpoint: str
client: AzureClientConfig


class GeneralAuthenticationConfig(BaseConfig):
jwt: JWTConfig = JWTConfig()


class OAuthAuthenticationConfig(GeneralAuthenticationConfig):
provider: t.Literal["oauth"] = pydantic.Field(
default="oauth",
description="Indicates the use of OAuth for authentication, not to be changed.",
)
oauth: AuthOauthConfig = AuthOauthConfig()


class AzureAuthenticationConfig(GeneralAuthenticationConfig):
provider: t.Literal["azure"] = pydantic.Field(
default="azure",
description="Indicates the use of Azure AD for authentication, not to be changed.",
)
azure: AzureConfig


class PipelineConfig(BaseConfig):
timeout: int = pydantic.Field(
default=60,
Expand Down Expand Up @@ -374,9 +336,7 @@ class AppConfig(BaseConfig):
k8s: K8sConfig = K8sConfig(context="k3d-collab-cluster")
general: GeneralConfig = GeneralConfig()
extensions: ExtensionsConfig = ExtensionsConfig()
authentication: OAuthAuthenticationConfig | AzureAuthenticationConfig = (
OAuthAuthenticationConfig()
)
authentication: AuthenticationConfig = AuthenticationConfig()
prometheus: PrometheusConfig = PrometheusConfig()
database: DatabaseConfig = DatabaseConfig()
initial: InitialConfig = InitialConfig()
Expand Down
21 changes: 0 additions & 21 deletions backend/capellacollab/core/authentication/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,2 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0


from importlib import metadata

from capellacollab.config import config


def get_authentication_entrypoint():
try:
ep = next(
i
for i in metadata.entry_points().select(
group="capellacollab.authentication.providers"
)
if i.name == config.authentication.provider
)
return ep
except StopIteration:
raise ValueError(
"Unknown authentication provider " + config.authentication.provider
) from None
91 changes: 91 additions & 0 deletions backend/capellacollab/core/authentication/api_key_cookie.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import logging
import typing as t

import fastapi
import jwt
from fastapi import security
from jwt import exceptions as jwt_exceptions

from capellacollab.config import config

from . import exceptions, oidc

log = logging.getLogger(__name__)

auth_config = config.authentication


class JWTConfig:
_jwks_client = None

def __init__(self, oidc_config: oidc.OIDCProviderConfig):
self.oidc_config = oidc_config

if JWTConfig._jwks_client is None:
JWTConfig._jwks_client = jwt.PyJWKClient(
uri=self.oidc_config.get_jwks_uri()
)
self.jwks_client = JWTConfig._jwks_client


class JWTAPIKeyCookie(security.APIKeyCookie):
def __init__(self):
super().__init__(name="id_token", auto_error=True)
self.oidc_config = oidc.get_cached_oidc_config()
self.jwt_config = JWTConfig(self.oidc_config)

async def __call__(self, request: fastapi.Request) -> str:
token: str | None = await super().__call__(request)

Check warning on line 41 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L41

Added line #L41 was not covered by tests
dominik003 marked this conversation as resolved.
Show resolved Hide resolved

if not token:
raise exceptions.UnauthenticatedError()

Check warning on line 44 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L44

Added line #L44 was not covered by tests

token_decoded = self.validate_token(token)
return self.get_username(token_decoded)

Check warning on line 47 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L46-L47

Added lines #L46 - L47 were not covered by tests

def validate_token(self, token: str) -> dict[str, t.Any]:
try:
signing_key = self.jwt_config.jwks_client.get_signing_key_from_jwt(

Check warning on line 51 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L50-L51

Added lines #L50 - L51 were not covered by tests
token
)

return jwt.decode(

Check warning on line 55 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L55

Added line #L55 was not covered by tests
jwt=token,
key=signing_key.key,
algorithms=self.oidc_config.get_supported_signing_algorithms(),
audience=self.oidc_config.get_client_id(),
issuer=self.oidc_config.get_issuer(),
options={"require": ["exp", "iat"]},
)
except jwt_exceptions.ExpiredSignatureError:
raise exceptions.TokenSignatureExpired()

Check warning on line 64 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L64

Added line #L64 was not covered by tests
except jwt_exceptions.InvalidIssuerError:
log.exception(

Check warning on line 66 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L66

Added line #L66 was not covered by tests
"Expected issuer '%s'. Got '%s'",
self.oidc_config.get_issuer(),
jwt.decode(
jwt=token,
options={"verify_signature": False},
)["iss"],
)
raise exceptions.JWTValidationFailed()
except jwt_exceptions.PyJWTError:
log.exception("JWT validation failed", exc_info=True)
raise exceptions.JWTValidationFailed()

Check warning on line 77 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L74-L77

Added lines #L74 - L77 were not covered by tests

@classmethod
def get_username(cls, token_decoded: dict[str, str]) -> str:
return token_decoded[auth_config.mapping.username].strip()

@classmethod
def get_idp_identifier(cls, token_decoded: dict[str, str]) -> str:
return token_decoded[auth_config.mapping.identifier].strip()

@classmethod
def get_email(cls, token_decoded: dict[str, str]) -> str | None:
if auth_config.mapping.email:
return token_decoded.get(auth_config.mapping.email, None)
return None

Check warning on line 91 in backend/capellacollab/core/authentication/api_key_cookie.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/api_key_cookie.py#L91

Added line #L91 was not covered by tests
19 changes: 7 additions & 12 deletions backend/capellacollab/core/authentication/basic_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,15 @@


class HTTPBasicAuth(security.HTTPBasic):
async def __call__( # type: ignore
self, request: fastapi.Request
) -> str | None:
def __init__(self):
super().__init__(auto_error=True)

async def __call__(self, request: fastapi.Request) -> str: # type: ignore
credentials: security.HTTPBasicCredentials | None = (
await super().__call__(request)
)
if not credentials:
if self.auto_error:
raise exceptions.UnauthenticatedError()
return None
raise exceptions.UnauthenticatedError()

Check warning on line 28 in backend/capellacollab/core/authentication/basic_auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/basic_auth.py#L28

Added line #L28 was not covered by tests
with database.SessionLocal() as session:
user = user_crud.get_user_by_name(session, credentials.username)
db_token = (
Expand All @@ -38,15 +37,11 @@
)
if not db_token:
logger.info("Token invalid for user %s", credentials.username)
if self.auto_error:
raise exceptions.InvalidPersonalAccessTokenError()
return None
raise exceptions.InvalidPersonalAccessTokenError()

if db_token.expiration_date < datetime.date.today():
logger.info("Token expired for user %s", credentials.username)
if self.auto_error:
raise exceptions.PersonalAccessTokenExpired()
return None
raise exceptions.PersonalAccessTokenExpired()

Check warning on line 44 in backend/capellacollab/core/authentication/basic_auth.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/core/authentication/basic_auth.py#L44

Added line #L44 was not covered by tests
return self.get_username(credentials)

def get_username(self, credentials: security.HTTPBasicCredentials) -> str:
Expand Down
Loading
Loading