Skip to content

Commit

Permalink
feat: Rework authentication logic
Browse files Browse the repository at this point in the history
This commit serves two main purposes. First, it merges the code
for the two authentication providers, azure and oauth. To do this,
we no longer rely on the azure-specific MSAL library, but use a more
general library that we already use for non-azure providers.
Second, we change our authentication scheme from a
bearer token provided in the header and stored in a frontend service
to secure cookies. This eliminates an XSS attack vector as
the tokens are no longer accessible on the client side.
  • Loading branch information
dominik003 committed Jun 17, 2024
1 parent 7b8e50e commit 8ca343b
Show file tree
Hide file tree
Showing 38 changed files with 522 additions and 857 deletions.
1 change: 0 additions & 1 deletion backend/capellacollab/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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 Down
63 changes: 14 additions & 49 deletions backend/capellacollab/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,21 +231,14 @@ class AuthOauthClientConfig(BaseConfig):


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(
default="http://localhost:8083/default/.well-known/openid-configuration",
description="The URL of the OpenID Connect discovery document.",
examples=[
Expand All @@ -254,22 +247,6 @@ class AuthOauthEndpointsConfig(BaseConfig):
)


class AuthOauthConfig(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.",
)
client: AuthOauthClientConfig = AuthOauthClientConfig()
redirect_uri: str = pydantic.Field(
default="http://localhost:4200/oauth2/callback",
description="The URI to which the user is redirected after authentication.",
examples=["http://localhost:4200/oauth2/callback"],
alias="redirectURI",
)


class JWTConfig(BaseConfig):
username_claim: str = pydantic.Field(
default="sub",
Expand All @@ -278,34 +255,24 @@ class JWTConfig(BaseConfig):
)


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.",
class AuthenticationConfig(GeneralAuthenticationConfig):
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.",
)
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.",
client: AuthOauthClientConfig = AuthOauthClientConfig()
redirect_uri: str = pydantic.Field(
default="http://localhost:4200/oauth2/callback",
description="The URI to which the user is redirected after authentication.",
examples=["http://localhost:4200/oauth2/callback"],
alias="redirectURI",
)
azure: AzureConfig


class PipelineConfig(BaseConfig):
Expand Down Expand Up @@ -374,9 +341,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
49 changes: 49 additions & 0 deletions backend/capellacollab/core/authentication/api_key_cookie.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import logging
import typing as t

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

from capellacollab.config import config

from . import exceptions, keystore

log = logging.getLogger(__name__)


class JWTAPIKeyCookie(security.APIKeyCookie):
def __init__(self):
super().__init__(name="id_token", auto_error=True)

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

if not token:
raise exceptions.UnauthenticatedError()

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

def get_username(self, token_decoded: dict[str, str]) -> str:
return token_decoded[config.authentication.jwt.username_claim].strip()

def validate_token(self, token: str) -> dict[str, t.Any]:
try:
jwt_cfg = keystore.get_jwk_cfg(token)
except Exception:
log.exception(
"Couldn't determine JWK configuration", exc_info=True
)
raise exceptions.JWTInvalidToken()
try:
return jwt.decode(token, **jwt_cfg)
except jwt_exceptions.ExpiredSignatureError:
raise exceptions.TokenSignatureExpired()
except (jwt_exceptions.JWTError, jwt_exceptions.JWTClaimsError):
log.exception("JWT validation failed", exc_info=True)
raise exceptions.JWTValidationFailed()
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()
with database.SessionLocal() as session:
user = user_crud.get_user_by_name(session, credentials.username)
db_token = (
Expand All @@ -38,15 +37,11 @@ async def __call__( # type: ignore
)
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()
return self.get_username(credentials)

def get_username(self, credentials: security.HTTPBasicCredentials) -> str:
Expand Down
44 changes: 38 additions & 6 deletions backend/capellacollab/core/authentication/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def __init__(self, scheme: str):
"Use 'basic' or 'bearer' instead"
),
err_code="UNKNOWN_SCHEME",
headers={"WWW-Authenticate": "Bearer, Basic"},
headers={"WWW-Authenticate": "Basic, Cookie"},
)


Expand All @@ -67,7 +67,7 @@ def __init__(self):
title="Token signature expired",
reason="The Signature of the token is expired. Please refresh the token or request a new access token.",
err_code="TOKEN_SIGNATURE_EXPIRED",
headers={"WWW-Authenticate": "Bearer, Basic"},
headers={"WWW-Authenticate": "Basic, Cookie"},
)


Expand All @@ -78,7 +78,7 @@ def __init__(self):
title="Refresh token signature expired",
reason="The Signature of the refresh token is expired. Please request a new access token.",
err_code="REFRESH_TOKEN_EXPIRED",
headers={"WWW-Authenticate": "Bearer, Basic"},
headers={"WWW-Authenticate": "Basic, Cookie"},
)


Expand Down Expand Up @@ -109,7 +109,7 @@ def __init__(self):
title="Unauthenticated",
reason="Not authenticated",
err_code="UNAUTHENTICATED",
headers={"WWW-Authenticate": "Bearer, Basic"},
headers={"WWW-Authenticate": "Basic, Cookie"},
)


Expand All @@ -120,7 +120,39 @@ def __init__(self):
title="Personal access token not valid.",
reason="The used token is not valid.",
err_code="BASIC_TOKEN_INVALID",
headers={"WWW-Authenticate": "Bearer, Basic"},
headers={"WWW-Authenticate": "Basic, Cookie"},
)


class RequiredCookieMissingError(core_exceptions.BaseError):
def __init__(self, cookie_name: str):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
title=f"No {cookie_name} provided as cookie.",
reason=f"There was no {cookie_name} cookie provided.",
err_code="REQUIRED_COOKIE_MISSING",
)


class NonceMismatchError(core_exceptions.BaseError):
def __init__(
self,
):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
title="The nonce values do not match.",
reason="The nonce value provided in the identity token and secure cookie do not match.",
err_code="NONCE_VALUE_MISMATCH",
)


class RefreshTokenCookieMissingError(core_exceptions.BaseError):
def __init__(self):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
title="No refresh token provided.",
reason="There was no refresh token cookie provided",
err_code="NO_REFRESH_TOKEN_COOKIE",
)


Expand All @@ -134,5 +166,5 @@ def __init__(self):
"Please request a new access token."
),
err_code="PAT_EXPIRED",
headers={"WWW-Authenticate": "Bearer, Basic"},
headers={"WWW-Authenticate": "Basic, Cookie"},
)
Loading

0 comments on commit 8ca343b

Please sign in to comment.