diff --git a/backend/capellacollab/alembic/versions/c9f30ccd4650_add_basic_auth_token.py b/backend/capellacollab/alembic/versions/c9f30ccd4650_add_basic_auth_token.py new file mode 100644 index 0000000000..2e0ffbe554 --- /dev/null +++ b/backend/capellacollab/alembic/versions/c9f30ccd4650_add_basic_auth_token.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add basic auth token + +Revision ID: c9f30ccd4650 +Revises: d8cf851562cd +Create Date: 2023-09-06 14:42:53.016924 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c9f30ccd4650" +down_revision = "d8cf851562cd" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "basic_auth_token", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("hash", sa.String(), nullable=False), + sa.Column("expiration_date", sa.Date(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_basic_auth_token_id"), + "basic_auth_token", + ["id"], + unique=False, + ) + # ### end Alembic commands ### diff --git a/backend/capellacollab/core/authentication/basic_auth.py b/backend/capellacollab/core/authentication/basic_auth.py new file mode 100644 index 0000000000..8564c7d4ba --- /dev/null +++ b/backend/capellacollab/core/authentication/basic_auth.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime +import logging + +import fastapi +from fastapi import security, status + +from capellacollab.core import database +from capellacollab.users import crud as user_crud +from capellacollab.users.tokens import crud as token_crud + +logger = logging.getLogger(__name__) + + +class HTTPBasicAuth(security.HTTPBasic): + async def __call__( # type: ignore + self, request: fastapi.Request + ) -> security.HTTPBasicCredentials | None: + credentials: security.HTTPBasicCredentials | None = ( + await super().__call__(request) + ) + if not credentials: + if self.auto_error: + raise fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Basic"}, + ) + return None + with database.SessionLocal() as session: + user = user_crud.get_user_by_name(session, credentials.username) + token_data = None + if user: + token_data = token_crud.get_token( + session, credentials.password, user.id + ) + if not token_data or not user or token_data.user_id != user.id: + logger.error("Token invalid for user %s", credentials.username) + if self.auto_error: + raise fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "err_code": "TOKEN_INVALID", + "reason": "The used token is not valid.", + }, + headers={"WWW-Authenticate": "Basic"}, + ) + return None + + if token_data.expiration_date < datetime.date.today(): + logger.error("Token expired for user %s", credentials.username) + if self.auto_error: + raise fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "err_code": "token_exp", + "reason": "The Signature of the token is expired. Please request a new access token.", + }, + headers={"WWW-Authenticate": "Basic"}, + ) + return None + return credentials diff --git a/backend/capellacollab/core/authentication/helper.py b/backend/capellacollab/core/authentication/helper.py index f4cdde257a..2a9cabea5c 100644 --- a/backend/capellacollab/core/authentication/helper.py +++ b/backend/capellacollab/core/authentication/helper.py @@ -7,5 +7,7 @@ from capellacollab.config import config -def get_username(token: dict[str, t.Any]) -> str: - return token[config["authentication"]["jwt"]["usernameClaim"]].strip() +def get_username(token: dict[str, t.Any], is_bearer: bool = True) -> str: + if is_bearer: + return token[config["authentication"]["jwt"]["usernameClaim"]].strip() + return token["username"] diff --git a/backend/capellacollab/core/authentication/injectables.py b/backend/capellacollab/core/authentication/injectables.py index 363ad9ede7..635ad62c0a 100644 --- a/backend/capellacollab/core/authentication/injectables.py +++ b/backend/capellacollab/core/authentication/injectables.py @@ -3,11 +3,15 @@ from __future__ import annotations +import logging +import typing as t + import fastapi from fastapi import status +from fastapi.security import utils as fastapi_utils from capellacollab.core import database -from capellacollab.core.authentication import jwt_bearer +from capellacollab.core.authentication import basic_auth, jwt_bearer from capellacollab.projects import crud as projects_crud from capellacollab.projects import models as projects_models from capellacollab.projects.users import crud as projects_users_crud @@ -17,6 +21,42 @@ from . import helper +logger = logging.getLogger(__name__) + + +async def get_username(request: fastapi.Request) -> str: + token, scheme = await get_token(request) + + if not token: + raise fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is none and username cannot be derived", + ) + + return helper.get_username(token, is_bearer=scheme.lower() == "bearer") + + +async def get_token( + request: fastapi.Request, auto_error: bool = True +) -> tuple[dict[str, t.Any] | None, str]: + scheme, _ = fastapi_utils.get_authorization_scheme_param( + request.headers.get("Authorization") + ) + token = None + match scheme.lower(): + case "bearer": + token = await jwt_bearer.JWTBearer(auto_error=auto_error)(request) + case "basic": + basic_creds = await basic_auth.HTTPBasicAuth( + auto_error=auto_error + )(request) + if basic_creds: + token = basic_creds.model_dump() + case _: + logger.error("Authentification scheme unknown") + + return token, scheme + class RoleVerification: def __init__(self, required_role: users_models.Role, verify: bool = True): @@ -25,10 +65,9 @@ def __init__(self, required_role: users_models.Role, verify: bool = True): def __call__( self, - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(get_username), db=fastapi.Depends(database.get_db), ) -> bool: - username = helper.get_username(token) if not (user := users_crud.get_user_by_name(db, username)): if self.verify: raise fastapi.HTTPException( @@ -73,10 +112,9 @@ def __init__( def __call__( self, project_slug: str, - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(get_username), db=fastapi.Depends(database.get_db), ) -> bool: - username = helper.get_username(token) if not (user := users_crud.get_user_by_name(db, username)): if self.verify: raise fastapi.HTTPException( diff --git a/backend/capellacollab/core/authentication/provider/azure/routes.py b/backend/capellacollab/core/authentication/provider/azure/routes.py index aa055e4584..b1fa7e93cb 100644 --- a/backend/capellacollab/core/authentication/provider/azure/routes.py +++ b/backend/capellacollab/core/authentication/provider/azure/routes.py @@ -94,9 +94,11 @@ async def logout(jwt_decoded=fastapi.Depends(JWTBearer())): @router.get("/tokens", name="Validate the token") async def validate_token( scope: Role | None, - token=fastapi.Depends(JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), db: orm.Session = fastapi.Depends(database.get_db), ): if scope and scope.ADMIN: - auth_injectables.RoleVerification(required_role=Role.ADMIN)(token, db) - return token + auth_injectables.RoleVerification(required_role=Role.ADMIN)( + username, db + ) + return username diff --git a/backend/capellacollab/core/authentication/provider/oauth/routes.py b/backend/capellacollab/core/authentication/provider/oauth/routes.py index c368c6ec3c..1a45b31da9 100644 --- a/backend/capellacollab/core/authentication/provider/oauth/routes.py +++ b/backend/capellacollab/core/authentication/provider/oauth/routes.py @@ -52,9 +52,11 @@ async def logout(): @router.get("/tokens", name="Validate the token") async def validate_token( scope: Role | None, - token=fastapi.Depends(JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), db: orm.Session = fastapi.Depends(database.get_db), ): if scope and scope.ADMIN: - auth_injectables.RoleVerification(required_role=Role.ADMIN)(token, db) - return token + auth_injectables.RoleVerification(required_role=Role.ADMIN)( + username, db + ) + return username diff --git a/backend/capellacollab/core/database/models.py b/backend/capellacollab/core/database/models.py index 31a1e92898..0b85bc619a 100644 --- a/backend/capellacollab/core/database/models.py +++ b/backend/capellacollab/core/database/models.py @@ -21,3 +21,4 @@ import capellacollab.tools.models import capellacollab.users.events.models import capellacollab.users.models +import capellacollab.users.tokens diff --git a/backend/capellacollab/core/logging/__init__.py b/backend/capellacollab/core/logging/__init__.py index d7ebb059e4..26bef07b4f 100644 --- a/backend/capellacollab/core/logging/__init__.py +++ b/backend/capellacollab/core/logging/__init__.py @@ -15,7 +15,7 @@ from capellacollab import config from capellacollab.core.authentication import helper as auth_helper -from capellacollab.core.authentication import jwt_bearer +from capellacollab.core.authentication import injectables as auth_injectables LOGGING_LEVEL = config.config["logging"]["level"] @@ -59,8 +59,13 @@ async def dispatch( self, request: fastapi.Request, call_next: base.RequestResponseEndpoint ): username = "anonymous" - if token := await jwt_bearer.JWTBearer(auto_error=False)(request): - username = auth_helper.get_username(token) + token, scheme = await auth_injectables.get_token( + request, auto_error=False + ) + if token: + username = auth_helper.get_username( + token, scheme.lower() == "bearer" + ) request.state.user_name = username diff --git a/backend/capellacollab/projects/routes.py b/backend/capellacollab/projects/routes.py index ee78f1ee95..158afe75f6 100644 --- a/backend/capellacollab/projects/routes.py +++ b/backend/capellacollab/projects/routes.py @@ -13,7 +13,6 @@ from capellacollab.core import database from capellacollab.core import logging as core_logging from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication import jwt_bearer from capellacollab.projects import injectables as projects_injectables from capellacollab.projects.events import routes as projects_events_routes from capellacollab.projects.toolmodels import routes as toolmodels_routes @@ -45,14 +44,14 @@ def get_projects( users_injectables.get_own_user ), db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), log: logging.LoggerAdapter = fastapi.Depends( core_logging.get_request_logger ), ) -> abc.Sequence[models.DatabaseProject]: if auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db): + )(username, db): log.debug("Fetching all projects") return crud.get_projects(db) diff --git a/backend/capellacollab/projects/toolmodels/backups/routes.py b/backend/capellacollab/projects/toolmodels/backups/routes.py index 660c3531d6..c2112efdd2 100644 --- a/backend/capellacollab/projects/toolmodels/backups/routes.py +++ b/backend/capellacollab/projects/toolmodels/backups/routes.py @@ -11,9 +11,7 @@ import capellacollab.settings.modelsources.t4c.repositories.interface as t4c_repository_interface from capellacollab.core import credentials, database -from capellacollab.core.authentication import helper as auth_helper from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication import jwt_bearer from capellacollab.projects.toolmodels import ( injectables as toolmodels_injectables, ) @@ -73,7 +71,7 @@ def create_backup( toolmodels_injectables.get_existing_capella_model ), db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ): git_model = git_injectables.get_existing_git_model( body.git_model_id, capella_model, db @@ -123,7 +121,7 @@ def create_backup( k8s_cronjob_id=reference, git_model=git_model, t4c_model=t4c_model, - created_by=auth_helper.get_username(token), + created_by=username, model=capella_model, t4c_username=username, t4c_password=password, @@ -139,7 +137,7 @@ def delete_pipeline( injectables.get_existing_pipeline ), db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), force: bool = False, ): try: @@ -159,7 +157,7 @@ def delete_pipeline( force and auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token=token, db=db) + )(username=username, db=db) ): raise exceptions.PipelineOperationFailedT4CServerUnreachable( exceptions.PipelineOperation.DELETE diff --git a/backend/capellacollab/projects/users/routes.py b/backend/capellacollab/projects/users/routes.py index 08560add3b..3a3b0709ab 100644 --- a/backend/capellacollab/projects/users/routes.py +++ b/backend/capellacollab/projects/users/routes.py @@ -8,7 +8,6 @@ from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication import jwt_bearer from capellacollab.projects import injectables as projects_injectables from capellacollab.projects import models as projects_models from capellacollab.users import crud as users_crud @@ -80,11 +79,11 @@ def get_current_user( projects_injectables.get_existing_project ), db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ) -> models.ProjectUserAssociation | models.ProjectUser: if auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db): + )(username, db): return models.ProjectUser( role=models.ProjectUserRole.ADMIN, permission=models.ProjectUserPermission.WRITE, diff --git a/backend/capellacollab/sessions/hooks/t4c.py b/backend/capellacollab/sessions/hooks/t4c.py index 03011f7755..fb3944d72c 100644 --- a/backend/capellacollab/sessions/hooks/t4c.py +++ b/backend/capellacollab/sessions/hooks/t4c.py @@ -81,6 +81,7 @@ def configuration_hook( for repository in t4c_repositories: try: + token_username = auth_injectables.get_username(token) repo_interface.add_user_to_repository( repository.instance, repository.name, @@ -88,7 +89,7 @@ def configuration_hook( password=environment["T4C_PASSWORD"], is_admin=auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db), + )(token_username, db), ) except requests.RequestException: warnings.append( diff --git a/backend/capellacollab/sessions/injectables.py b/backend/capellacollab/sessions/injectables.py index 964a18746d..c0259f9bb3 100644 --- a/backend/capellacollab/sessions/injectables.py +++ b/backend/capellacollab/sessions/injectables.py @@ -6,9 +6,7 @@ from sqlalchemy import orm from capellacollab.core import database -from capellacollab.core.authentication import helper as auth_helper from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication import jwt_bearer from capellacollab.users import models as users_models from . import crud, models @@ -17,7 +15,7 @@ def get_existing_session( session_id: str, db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ) -> models.DatabaseSession: if not (session := crud.get_session_by_id(db, session_id)): raise fastapi.HTTPException( @@ -27,10 +25,10 @@ def get_existing_session( }, ) if not ( - session.owner_name == auth_helper.get_username(token) + session.owner_name == username or auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db) + )(username, db) ): raise fastapi.HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index 36aa16e817..7d41d4efb5 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -87,11 +87,11 @@ def get_current_sessions( users_injectables.get_own_user ), db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ): if auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db): + )(username, db): return sessions.inject_attrs_in_sessions(crud.get_sessions(db)) if not any( @@ -591,11 +591,11 @@ def get_sessions_for_user( users_injectables.get_own_user ), db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ): if user != current_user and not auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db): + )(username, db): raise fastapi.HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ diff --git a/backend/capellacollab/users/injectables.py b/backend/capellacollab/users/injectables.py index 679efc5258..58732e7c23 100644 --- a/backend/capellacollab/users/injectables.py +++ b/backend/capellacollab/users/injectables.py @@ -7,17 +7,15 @@ from sqlalchemy import orm from capellacollab.core import database -from capellacollab.core.authentication import helper as auth_helper -from capellacollab.core.authentication import jwt_bearer +from capellacollab.core.authentication import injectables as auth_injectables from . import crud, exceptions, models def get_own_user( db: orm.Session = fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ) -> models.DatabaseUser: - username = auth_helper.get_username(token) if user := crud.get_user_by_name(db, username): return user @@ -28,10 +26,10 @@ def get_own_user( def get_existing_user( user_id: int | t.Literal["current"], db=fastapi.Depends(database.get_db), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ) -> models.DatabaseUser: if user_id == "current": - return get_own_user(db, token) + return get_own_user(db, username) if user := crud.get_user_by_id(db, user_id): return user diff --git a/backend/capellacollab/users/routes.py b/backend/capellacollab/users/routes.py index f41401eceb..d799b6e624 100644 --- a/backend/capellacollab/users/routes.py +++ b/backend/capellacollab/users/routes.py @@ -13,6 +13,7 @@ from capellacollab.users.events import crud as events_crud from capellacollab.users.events import models as events_models from capellacollab.users.events import routes as events_routes +from capellacollab.users.tokens import routes as tokens_routes from . import crud, injectables, models @@ -135,3 +136,4 @@ def delete_user( router.include_router(session_routes.users_router, tags=["Users - Sessions"]) router.include_router(events_routes.router, tags=["Users - History"]) +router.include_router(tokens_routes.router, tags=["Users - Token"]) diff --git a/backend/capellacollab/users/tokens/__init__.py b/backend/capellacollab/users/tokens/__init__.py new file mode 100644 index 0000000000..677cdfe33a --- /dev/null +++ b/backend/capellacollab/users/tokens/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/users/tokens/crud.py b/backend/capellacollab/users/tokens/crud.py new file mode 100644 index 0000000000..0d52f0dbaf --- /dev/null +++ b/backend/capellacollab/users/tokens/crud.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime +from collections import abc + +import argon2 +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.core import credentials + +from . import models + + +def create_token( + db: orm.Session, user_id: int, description: str +) -> tuple[models.DatabaseUserTokenModel, str]: + password = credentials.generate_password(32) + ph = argon2.PasswordHasher() + token_data = models.DatabaseUserTokenModel( + user_id=user_id, + hash=ph.hash(password), + expiration_date=datetime.datetime.now() + datetime.timedelta(days=30), + description=description, + ) + db.add(token_data) + db.commit() + return token_data, password + + +def get_token( + db: orm.Session, password: str, user_id: int +) -> models.DatabaseUserTokenModel | None: + ph = argon2.PasswordHasher() + token_list = get_token_by_user(db, user_id) + if token_list: + for token in token_list: + try: + ph.verify(token.hash, password) + return token + except argon2.exceptions.VerifyMismatchError: + pass + return None + + +def get_token_by_user( + db: orm.Session, user_id: int +) -> abc.Sequence[models.DatabaseUserTokenModel] | None: + return ( + db.execute( + sa.select(models.DatabaseUserTokenModel).where( + models.DatabaseUserTokenModel.user_id == user_id + ) + ) + .scalars() + .all() + ) + + +def delete_token( + db: orm.Session, existing_token: models.DatabaseUserTokenModel +) -> models.DatabaseUserTokenModel: + db.delete(existing_token) + db.commit() + return existing_token diff --git a/backend/capellacollab/users/tokens/models.py b/backend/capellacollab/users/tokens/models.py new file mode 100644 index 0000000000..ccb2f3eca2 --- /dev/null +++ b/backend/capellacollab/users/tokens/models.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import datetime + +import sqlalchemy as sa +from sqlalchemy import orm + +from capellacollab.core import database + + +class DatabaseUserTokenModel(database.Base): + __tablename__ = "basic_auth_token" + + id: orm.Mapped[int] = orm.mapped_column( + primary_key=True, index=True, autoincrement=True + ) + user_id: orm.Mapped[int] = orm.mapped_column(sa.ForeignKey("users.id")) + hash: orm.Mapped[str] + expiration_date: orm.Mapped[datetime.date] + description: orm.Mapped[str] diff --git a/backend/capellacollab/users/tokens/routes.py b/backend/capellacollab/users/tokens/routes.py new file mode 100644 index 0000000000..a9dbfb937a --- /dev/null +++ b/backend/capellacollab/users/tokens/routes.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.users import injectables as user_injectables +from capellacollab.users import models as users_models + +from . import crud + +router = fastapi.APIRouter( + dependencies=[ + fastapi.Depends( + auth_injectables.RoleVerification( + required_role=users_models.Role.USER + ) + ) + ] +) + + +@router.post("/current/token") +def create_token_for_user( + user: users_models.DatabaseUser = fastapi.Depends( + user_injectables.get_own_user + ), + db: orm.Session = fastapi.Depends(database.get_db), + description: str = fastapi.Body(), +): + _, password = crud.create_token(db, user.id, description) + return password + + +@router.get("/current/tokens") +def get_all_token_of_user( + user: users_models.DatabaseUser = fastapi.Depends( + user_injectables.get_own_user + ), + db: orm.Session = fastapi.Depends(database.get_db), +): + return crud.get_token_by_user(db, user.id) + + +@router.delete("/current/token/{id}") +def delete_token_for_user( + id: int, + user: users_models.DatabaseUser = fastapi.Depends( + user_injectables.get_own_user + ), + db: orm.Session = fastapi.Depends(database.get_db), +): + token_list = crud.get_token_by_user(db, user.id) + if token_list: + token = [token for token in token_list if token.id == id][0] + return crud.delete_token(db, token) + return None diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f165432c89..31ec5bee6e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "starlette-prometheus", "fastapi-pagination>=0.12.5", "aiohttp", + "argon2-cffi", ] [project.urls] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ec9e03628d..3f72fb968b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -140,7 +140,7 @@ def project_user( @pytest.fixture() def client() -> testclient.TestClient: - return testclient.TestClient(app) + return testclient.TestClient(app, headers={"Authorization": "bearer"}) def delete_all_tables_if_existent(_engine: sqlalchemy.engine.Engine) -> bool: diff --git a/backend/tests/users/test_tokens.py b/backend/tests/users/test_tokens.py new file mode 100644 index 0000000000..d3e52c5bf4 --- /dev/null +++ b/backend/tests/users/test_tokens.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import json + +import pytest +import responses +from fastapi import testclient + +from capellacollab.users import models as user_models + + +@responses.activate +@pytest.mark.usefixtures("user") +def test_get_user_token(client: testclient.TestClient): + response = client.post( + f"/api/v1/users/current/token", json="my_test_description" + ) + assert response.status_code == 200 + + +@responses.activate +@pytest.mark.usefixtures("user") +def test_get_user_tokens(client: testclient.TestClient): + response = client.get(f"/api/v1/users/current/tokens") + assert response.status_code == 200 + + +@responses.activate +def test_create_and_use_basic_token( + client: testclient.TestClient, user: user_models.User +): + response = client.post( + f"/api/v1/users/current/token", json="my_test_description" + ) + assert response.status_code == 200 + + password = response.content.decode("ascii").replace('"', "") + token_string = user.name + ":" + password + token = base64.b64encode(token_string.encode("ascii")) + basic_response = client.post( + f"/api/v1/users/current/token", + headers={"Authorization": f"basic {token.decode('ascii')}"}, + json="my_test_description2", + ) + assert basic_response.status_code == 200 + + +responses.activate + + +def test_create_and_delete_token( + client: testclient.TestClient, user: user_models.User +): + response = client.post( + f"/api/v1/users/current/token", json="my_test_description" + ) + assert response.status_code == 200 + + response = client.delete(f"/api/v1/users/current/token/1") + assert response.status_code == 200 + + +responses.activate + + +def test_token_lifecycle( + client: testclient.TestClient, user: user_models.User +): + response = client.post( + f"/api/v1/users/current/token", json="my_test_description" + ) + assert response.status_code == 200 + + response = client.get(f"/api/v1/users/current/tokens") + response_string = response.content.decode("utf-8") + assert len(json.loads(response_string)) == 1 + + response = client.delete(f"/api/v1/users/current/token/1") + assert response.status_code == 200 + + response = client.get(f"/api/v1/users/current/tokens") + response_string = response.content.decode("utf-8") + assert len(json.loads(response_string)) == 0 diff --git a/capella-dockerimages b/capella-dockerimages deleted file mode 160000 index 2bd3343c49..0000000000 --- a/capella-dockerimages +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2bd3343c49441fb420931f880ee6e86bba3b24cb diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 193e57803b..409f2f1e3c 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -5,6 +5,7 @@ import { NgModule } from '@angular/core'; import { Data, RouterModule, Routes } from '@angular/router'; +import { BasicAuthTokenComponent } from 'src/app/general/auth/basic_auth_token/basic_auth_token.component'; import { JobRunOverviewComponent } from 'src/app/projects/models/backup-settings/job-run-overview/job-run-overview.component'; import { PipelineRunWrapperComponent } from 'src/app/projects/models/backup-settings/pipeline-runs/wrapper/pipeline-run-wrapper/pipeline-run-wrapper.component'; import { ViewLogsDialogComponent } from 'src/app/projects/models/backup-settings/view-logs-dialog/view-logs-dialog.component'; @@ -423,6 +424,11 @@ const routes: Routes = [ data: { breadcrumb: 'Events' }, component: EventsComponent, }, + { + path: 'tokens', + data: { breadcrumb: 'tokens' }, + component: BasicAuthTokenComponent, + }, ], }, { diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 89b4c8187d..583c0ed5eb 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -44,6 +44,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { CookieModule } from 'ngx-cookie'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { ToastrModule } from 'ngx-toastr'; +import { BasicAuthTokenComponent } from 'src/app/general/auth/basic_auth_token/basic_auth_token.component'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { EventsComponent } from './events/events.component'; @@ -139,6 +140,7 @@ import { SettingsComponent } from './settings/settings.component'; AlertSettingsComponent, AppComponent, AuthComponent, + BasicAuthTokenComponent, BreadcrumbsComponent, ButtonSkeletonLoaderComponent, ChooseInitComponent, diff --git a/frontend/src/app/general/auth/basic_auth_service/basic_auth_token.service.ts b/frontend/src/app/general/auth/basic_auth_service/basic_auth_token.service.ts new file mode 100644 index 0000000000..be17ad4965 --- /dev/null +++ b/frontend/src/app/general/auth/basic_auth_service/basic_auth_token.service.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, tap } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class TokenService { + constructor(private http: HttpClient) {} + private readonly _tokens = new BehaviorSubject( + undefined + ); + + readonly tokens$ = this._tokens.asObservable(); + + loadTokens(): void { + this.http + .get(environment.backend_url + '/users/current/tokens') + .subscribe({ + next: (token) => this._tokens.next(token), + error: () => this._tokens.next(undefined), + }); + } + + createToken(tokenDescription: string): Observable { + const password = this.http + .post( + environment.backend_url + `/users/current/token`, + tokenDescription + ) + .pipe(tap(() => this.loadTokens())); + + return password; + } + + deleteToken(token: Token): Observable { + return this.http + .delete( + environment.backend_url + `/users/current/token/${token.id}` + ) + .pipe(tap(() => this.loadTokens())); + } +} + +export type Token = { + description: string; + expiration_date: string; + id: number; +}; diff --git a/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.css b/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.css new file mode 100644 index 0000000000..d49deaffd7 --- /dev/null +++ b/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.css @@ -0,0 +1,4 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ diff --git a/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.html b/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.html new file mode 100644 index 0000000000..e54267d5b9 --- /dev/null +++ b/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.html @@ -0,0 +1,46 @@ + + +
+

Token Overview

+
+
+ + +
+
Generated Password: {{ password }}
+ +
+ + + + Description: {{ token.description }}
+ Expiration date: {{ token.expiration_date }} +
+ +
+
+
diff --git a/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.ts b/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.ts new file mode 100644 index 0000000000..053246001a --- /dev/null +++ b/frontend/src/app/general/auth/basic_auth_token/basic_auth_token.component.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Component, OnInit } from '@angular/core'; +import { + TokenService, + Token, +} from 'src/app/general/auth/basic_auth_service/basic_auth_token.service'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; + +@Component({ + selector: 'app-token-settings', + templateUrl: './basic_auth_token.component.html', + styleUrls: ['./basic_auth_token.component.css'], +}) +export class BasicAuthTokenComponent implements OnInit { + tokenDescription: string = ''; + password?: string; + constructor( + public tokenService: TokenService, + private toastService: ToastService + ) {} + + ngOnInit() { + this.tokenService.loadTokens(); + } + + createNewToken(description?: string) { + if (!description) { + description = this.tokenDescription; + } + description = 'Token-overview-' + description; + this.tokenService + .createToken(description) + .subscribe((token) => (this.password = token.replaceAll('"', ''))); + } + + deleteToken(token: Token) { + this.tokenService.deleteToken(token).subscribe(); + } + + showClipboardMessage(): void { + this.toastService.showSuccess( + 'Password copied', + 'The password was copied to your clipboard.' + ); + } +} diff --git a/frontend/src/app/general/header/header.component.html b/frontend/src/app/general/header/header.component.html index b73254e93c..26fbdd4487 100644 --- a/frontend/src/app/general/header/header.component.html +++ b/frontend/src/app/general/header/header.component.html @@ -64,6 +64,9 @@ > Events event_note + + Tokens key +