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..6a497e9299 --- /dev/null +++ b/backend/capellacollab/core/authentication/basic_auth.py @@ -0,0 +1,70 @@ +# 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 + ) -> tuple[str | None, fastapi.HTTPException | None]: + credentials: security.HTTPBasicCredentials | None = ( + await super().__call__(request) + ) + if not credentials: + error = fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) + if self.auto_error: + raise error + return None, error + 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) + error = fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "err_code": "TOKEN_INVALID", + "reason": "The used token is not valid.", + }, + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) + if self.auto_error: + raise error + return None, error + + if token_data.expiration_date < datetime.date.today(): + logger.error("Token expired for user %s", credentials.username) + error = 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": "Bearer, Basic"}, + ) + if self.auto_error: + raise error + return None, error + return self.get_username(credentials), None + + def get_username(self, credentials: security.HTTPBasicCredentials) -> str: + return credentials.model_dump()["username"] diff --git a/backend/capellacollab/core/authentication/helper.py b/backend/capellacollab/core/authentication/helper.py deleted file mode 100644 index f4cdde257a..0000000000 --- a/backend/capellacollab/core/authentication/helper.py +++ /dev/null @@ -1,11 +0,0 @@ -# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors -# SPDX-License-Identifier: Apache-2.0 - - -import typing as t - -from capellacollab.config import config - - -def get_username(token: dict[str, t.Any]) -> str: - return token[config["authentication"]["jwt"]["usernameClaim"]].strip() diff --git a/backend/capellacollab/core/authentication/injectables.py b/backend/capellacollab/core/authentication/injectables.py index 363ad9ede7..3e748f86bc 100644 --- a/backend/capellacollab/core/authentication/injectables.py +++ b/backend/capellacollab/core/authentication/injectables.py @@ -3,11 +3,13 @@ from __future__ import annotations +import logging + import fastapi from fastapi import status 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 @@ -15,7 +17,42 @@ from capellacollab.users import crud as users_crud from capellacollab.users import models as users_models -from . import helper +logger = logging.getLogger(__name__) + + +async def get_username( + basic: tuple[str | None, fastapi.HTTPException | None] = fastapi.Depends( + basic_auth.HTTPBasicAuth(auto_error=False) + ), + jwt: tuple[str | None, fastapi.HTTPException | None] = fastapi.Depends( + jwt_bearer.JWTBearer(auto_error=False) + ), +) -> str: + jwt_username, jwt_error = jwt + if jwt_username: + return jwt_username + + basic_username, basic_error = basic + if basic_username: + return basic_username + + if jwt_error: + raise jwt_error + if basic_error: + raise basic_error + + raise fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is none and username cannot be derived", + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) + + +async def get_username_not_injectable(request: fastapi.Request): + basic = await basic_auth.HTTPBasicAuth(auto_error=False)(request) + jwt = await jwt_bearer.JWTBearer(auto_error=False)(request) + + return await get_username(basic=basic, jwt=jwt) class RoleVerification: @@ -25,10 +62,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 +109,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/jwt_bearer.py b/backend/capellacollab/core/authentication/jwt_bearer.py index 40dd2b5239..ad11b2267e 100644 --- a/backend/capellacollab/core/authentication/jwt_bearer.py +++ b/backend/capellacollab/core/authentication/jwt_bearer.py @@ -11,8 +11,8 @@ import capellacollab.users.crud as users_crud import capellacollab.users.events.crud as events_crud +from capellacollab.config import config from capellacollab.core import database -from capellacollab.core.authentication import helper as auth_helper from . import get_authentication_entrypoint @@ -25,29 +25,42 @@ class JWTBearer(security.HTTPBearer): def __init__(self, auto_error: bool = True): super().__init__(auto_error=auto_error) - async def __call__( + async def __call__( # type: ignore self, request: fastapi.Request - ) -> dict[str, t.Any] | None: + ) -> tuple[str | None, fastapi.HTTPException | None]: credentials: security.HTTPAuthorizationCredentials | None = ( await super().__call__(request) ) if not credentials or credentials.scheme != "Bearer": + error = fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) if self.auto_error: - raise fastapi.HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) - return None + raise error + return None, error if token_decoded := self.validate_token(credentials.credentials): self.initialize_user(token_decoded) - return token_decoded - return None + return self.get_username(token_decoded), None + error = fastapi.HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer, Basic"}, + ) + if self.auto_error: + raise error + return None, error + + def get_username(self, token_decoded: dict[str, str]) -> str: + return token_decoded[ + config["authentication"]["jwt"]["usernameClaim"] + ].strip() def initialize_user(self, token_decoded: dict[str, str]): with database.SessionLocal() as session: - username: str = auth_helper.get_username(token_decoded) + username: str = self.get_username(token_decoded) if not users_crud.get_user_by_name(session, username): created_user = users_crud.create_user(session, username) users_crud.update_last_login(session, created_user) diff --git a/backend/capellacollab/core/authentication/provider/azure/routes.py b/backend/capellacollab/core/authentication/provider/azure/routes.py index aa055e4584..d97c98b7ac 100644 --- a/backend/capellacollab/core/authentication/provider/azure/routes.py +++ b/backend/capellacollab/core/authentication/provider/azure/routes.py @@ -14,7 +14,6 @@ from capellacollab.config import config from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication.helper import get_username from capellacollab.core.authentication.jwt_bearer import JWTBearer from capellacollab.core.authentication.schemas import ( RefreshTokenRequest, @@ -62,7 +61,9 @@ async def api_get_token( ) access_token = token["id_token"] - username = get_username(JWTBearer().validate_token(access_token)) + username = JWTBearer().get_username( + JWTBearer().validate_token(access_token) + ) if user := users_crud.get_user_by_name(db, username): users_crud.update_last_login(db, user) @@ -94,9 +95,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..d3484af644 100644 --- a/backend/capellacollab/core/authentication/provider/oauth/routes.py +++ b/backend/capellacollab/core/authentication/provider/oauth/routes.py @@ -7,7 +7,6 @@ import capellacollab.users.crud as users_crud from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables -from capellacollab.core.authentication.helper import get_username from capellacollab.core.authentication.jwt_bearer import JWTBearer from capellacollab.core.authentication.schemas import ( RefreshTokenRequest, @@ -31,7 +30,9 @@ async def api_get_token( ): token = get_token(body.code) - username = get_username(JWTBearer().validate_token(token["access_token"])) + username = JWTBearer().get_username( + JWTBearer().validate_token(token["access_token"]) + ) if user := users_crud.get_user_by_name(db, username): users_crud.update_last_login(db, user) @@ -52,9 +53,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..bfeaa8eae5 100644 --- a/backend/capellacollab/core/logging/__init__.py +++ b/backend/capellacollab/core/logging/__init__.py @@ -14,8 +14,7 @@ from starlette.middleware import base 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"] @@ -58,9 +57,12 @@ class AttachUserNameMiddleware(base.BaseHTTPMiddleware): 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) + try: + username = await auth_injectables.get_username_not_injectable( + request + ) + except fastapi.HTTPException: + username = "anonymous" 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/interface.py b/backend/capellacollab/sessions/hooks/interface.py index 63524729a0..1ac086379f 100644 --- a/backend/capellacollab/sessions/hooks/interface.py +++ b/backend/capellacollab/sessions/hooks/interface.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import abc -import typing as t from sqlalchemy import orm @@ -38,7 +37,7 @@ def configuration_hook( user: users_models.DatabaseUser, tool_version: tools_models.DatabaseVersion, tool: tools_models.DatabaseTool, - token: dict[str, t.Any], + username: str, **kwargs, ) -> tuple[ dict[str, str], diff --git a/backend/capellacollab/sessions/hooks/jupyter.py b/backend/capellacollab/sessions/hooks/jupyter.py index 78dd23d112..62e2ac8eee 100644 --- a/backend/capellacollab/sessions/hooks/jupyter.py +++ b/backend/capellacollab/sessions/hooks/jupyter.py @@ -46,7 +46,7 @@ def configuration_hook( db: orm.Session, user: users_models.DatabaseUser, tool: tools_models.DatabaseTool, - token: dict[str, t.Any], + username: str, **kwargs, ) -> tuple[ JupyterConfigEnvironment, @@ -63,7 +63,7 @@ def configuration_hook( "CSP_ORIGIN_HOST": f"{self._general_conf.get('scheme')}://{self._general_conf.get('host')}:{self._general_conf.get('port')}", } - volumes = self._get_project_share_volume_mounts(db, token, tool) + volumes = self._get_project_share_volume_mounts(db, username, tool) return environment, volumes, [] @@ -95,7 +95,7 @@ def _determine_base_url(self, username: str): def _get_project_share_volume_mounts( self, db: orm.Session, - token: dict[str, t.Any], + username: str, tool: tools_models.DatabaseTool, ) -> list[operators_models.PersistentVolume]: volumes = [] @@ -105,7 +105,7 @@ def _get_project_share_volume_mounts( for model in toolmodels_crud.get_models_by_tool(db, tool.id) if model.configuration and "workspace" in model.configuration - and self._is_project_member(model, token, db) + and self._is_project_member(model, username, db) ] for model in accessible_models_with_workspace_configuration: @@ -114,7 +114,7 @@ def _get_project_share_volume_mounts( operators_models.PersistentVolume( name=model.configuration["workspace"], read_only=not self._has_project_write_access( - model, token, db + model, username, db ), container_path=pathlib.PurePosixPath("/shared") / model.project.slug @@ -129,22 +129,22 @@ def _get_project_share_volume_mounts( def _is_project_member( self, model: toolmodels_models.DatabaseCapellaModel, - token: dict[str, t.Any], + username: str, db: orm.Session, ) -> bool: return auth_injectables.ProjectRoleVerification( required_role=projects_users_models.ProjectUserRole.USER, verify=False, - )(model.project.slug, token, db) + )(model.project.slug, username, db) def _has_project_write_access( self, model: toolmodels_models.DatabaseCapellaModel, - token: dict[str, t.Any], + username: str, db: orm.Session, ) -> bool: return auth_injectables.ProjectRoleVerification( required_role=projects_users_models.ProjectUserRole.USER, required_permission=projects_users_models.ProjectUserPermission.WRITE, verify=False, - )(model.project.slug, token, db) + )(model.project.slug, username, db) diff --git a/backend/capellacollab/sessions/hooks/t4c.py b/backend/capellacollab/sessions/hooks/t4c.py index 03011f7755..b006fbc3e2 100644 --- a/backend/capellacollab/sessions/hooks/t4c.py +++ b/backend/capellacollab/sessions/hooks/t4c.py @@ -40,7 +40,7 @@ def configuration_hook( db: orm.Session, user: users_models.DatabaseUser, tool_version: tools_models.DatabaseVersion, - token: dict[str, t.Any], + username: str, **kwargs, ) -> tuple[ T4CConfigEnvironment, @@ -88,7 +88,7 @@ def configuration_hook( password=environment["T4C_PASSWORD"], is_admin=auth_injectables.RoleVerification( required_role=users_models.Role.ADMIN, verify=False - )(token, db), + )(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..3ea95c6dd9 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -16,7 +16,6 @@ from capellacollab.core import credentials, database from capellacollab.core import models as core_models 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.projects.toolmodels import ( @@ -87,11 +86,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( @@ -284,7 +283,7 @@ def request_persistent_session( ), db: orm.Session = fastapi.Depends(database.get_db), operator: k8s.KubernetesOperator = fastapi.Depends(operators.get_operator), - token=fastapi.Depends(jwt_bearer.JWTBearer()), + username=fastapi.Depends(auth_injectables.get_username), ): log.info("Starting persistent session for user %s", user.name) @@ -305,7 +304,7 @@ def request_persistent_session( user=user, tool_version=version, tool=tool, - token=token, + username=username, operator=operator, ) environment |= hook_env @@ -591,11 +590,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..876bf861f2 100644 --- a/backend/capellacollab/users/injectables.py +++ b/backend/capellacollab/users/injectables.py @@ -7,18 +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 +25,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..ad568f40e2 --- /dev/null +++ b/backend/capellacollab/users/tokens/crud.py @@ -0,0 +1,65 @@ +# 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() 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..d2145a899e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -73,7 +73,7 @@ def fixture_executor_name(monkeypatch: pytest.MonkeyPatch) -> str: name = str(uuid1()) async def bearer_passthrough(self, request: fastapi.Request): - return {"sub": name} + return name, None monkeypatch.setattr(JWTBearer, "__call__", bearer_passthrough) @@ -99,6 +99,20 @@ def get_mock_own_user(): del app.dependency_overrides[users_injectables.get_own_user] +@pytest.fixture(name="unauthenticated_user") +def fixture_unauthenticated_user(db): + user = users_crud.create_user(db, str(uuid1()), users_models.Role.USER) + + def get_mock_own_user(): + return user + + app.dependency_overrides[ + users_injectables.get_own_user + ] = get_mock_own_user + yield user + del app.dependency_overrides[users_injectables.get_own_user] + + @pytest.fixture(name="project") def fixture_project(db: orm.Session) -> projects_models.DatabaseProject: return projects_crud.create_project(db, str(uuid1())) @@ -140,7 +154,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/projects/test_projects_routes.py b/backend/tests/projects/test_projects_routes.py index 165128fe8d..a09d2018ec 100644 --- a/backend/tests/projects/test_projects_routes.py +++ b/backend/tests/projects/test_projects_routes.py @@ -16,7 +16,7 @@ def test_get_projects_not_authenticated(client: testclient.TestClient): response = client.get("/api/v1/projects") - assert response.status_code == 403 + assert response.status_code == 401 assert response.json() == {"detail": "Not authenticated"} diff --git a/backend/tests/sessions/test_session_hooks.py b/backend/tests/sessions/test_session_hooks.py index 6230f459ab..36c1a73dc9 100644 --- a/backend/tests/sessions/test_session_hooks.py +++ b/backend/tests/sessions/test_session_hooks.py @@ -42,7 +42,7 @@ def configuration_hook( user: users_models.DatabaseUser, tool_version: tools_models.DatabaseVersion, tool: tools_models.DatabaseTool, - token: dict[str, t.Any], + username: str, **kwargs, ) -> tuple[dict[str, str], list[core_models.Message]]: self.configuration_hook_counter += 1 @@ -162,7 +162,7 @@ def test_session_creation_hook_is_called( user, db, mockoperator, - {"sub": "testuser"}, + "testuser", ) assert session_hook.configuration_hook_counter == 1 diff --git a/backend/tests/sessions/test_sessions_routes.py b/backend/tests/sessions/test_sessions_routes.py index 1b81017e25..2bd715b6c6 100644 --- a/backend/tests/sessions/test_sessions_routes.py +++ b/backend/tests/sessions/test_sessions_routes.py @@ -165,7 +165,7 @@ def get_mock_own_user(): def test_get_sessions_not_authenticated(client): response = client.get("/api/v1/sessions") - assert response.status_code == 403 + assert response.status_code == 401 assert response.json() == {"detail": "Not authenticated"} diff --git a/backend/tests/settings/test_alerts.py b/backend/tests/settings/test_alerts.py index 5d41c2997b..207f1f7089 100644 --- a/backend/tests/settings/test_alerts.py +++ b/backend/tests/settings/test_alerts.py @@ -47,7 +47,7 @@ def test_create_alert_not_authenticated(client: TestClient): json={"title": "test", "message": "test", "level": "success"}, ) - assert response.status_code == 403 + assert response.status_code == 401 assert response.json() == {"detail": "Not authenticated"} diff --git a/backend/tests/users/test_tokens.py b/backend/tests/users/test_tokens.py new file mode 100644 index 0000000000..30d92eeca2 --- /dev/null +++ b/backend/tests/users/test_tokens.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright DB Netz AG and the capella-collab-manager contributors +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import json + +import fastapi +import pytest +import responses +from fastapi import testclient + +from capellacollab.__main__ import app +from capellacollab.core.authentication.basic_auth import HTTPBasicAuth +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_use_basic_token( + client: testclient.TestClient, + unauthenticated_user: user_models.User, + monkeypatch: pytest.MonkeyPatch, +): + async def basic_passthrough(self, request: fastapi.Request): + return unauthenticated_user.name, None + + monkeypatch.setattr(HTTPBasicAuth, "__call__", basic_passthrough) + token_string = unauthenticated_user.name + ":" + "myTestPassword" + 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_use_wrong_basic_token(unauthenticated_user: user_models.User): + token_string = unauthenticated_user.name + ":" + "testPassword" + token = base64.b64encode(token_string.encode("ascii")) + basic_client = testclient.TestClient(app) + basic_response = basic_client.post( + f"/api/v1/users/current/token", + headers={"Authorization": f"basic {token.decode('ascii')}"}, + json="my_test_description", + ) + assert basic_response.status_code == 401 + + +@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/docs/user/docs/tokens.md b/docs/user/docs/tokens.md new file mode 100644 index 0000000000..ce605575f1 --- /dev/null +++ b/docs/user/docs/tokens.md @@ -0,0 +1,55 @@ + + +# Token Authentication + +To authenticate against the API you can either use a Bearer token (which the +browser usually uses) or authenticate with a longer lived basic authentication +token which can be used e.g. in scripts. + +## Token Creation + +To create a Token you can go to Profile > Token and insert a short description +to create a token. + + + +!!! info The password which is generated can only be copied. Make sure you save +it - you won't be able to access it again + +## Token Scope + +Basic authentication token have the same scope as the bearer token. + + +!!! warning + As currently Basic Authentication token have the same scope as you logged in your browser they can act as your user. Please do not share the password and if the information gets lost please revoke the token as soon as possible. + +## Revoke a Token + +In order to revoke a token go to Profile > Token. There you can see a list of +all tokens that belong to your user. Clicking on the trash symbol you can +delete a token which will no longer be valid to authenticate. + +## Token Usage + +The token created is a basic authentication token. There are different ways to +authenticate with that against the Collaboration Manager API. One example is: + +```zsh +curl --basic -u yourUsername:yourPassword https://baseurl/api/v1/users/current/tokens +``` + +or to work with the diagram cache + +```python +capellambse.model.MelodyModel( + path="path to the model on your machine", + diagram_cache={ + "path": "https://baseurl/api/projects/[yourProjectSlug]/[yourModelName]/diagrams/%s", + "username": "yourUsername", + "password": "yourPassword", + }) +``` diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 193e57803b..d700b804cd 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..04f6ac7f62 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..ff78b17ec9 --- /dev/null +++ b/frontend/src/app/general/auth/basic-auth-token/basic-auth-token.component.html @@ -0,0 +1,78 @@ + + +
+

Basic Authentication Token

+
+
+
+ + +
+
+ + Generated Token: + + {{ + password + }} + + +
+ Make sure you save the token - you won't be able to access it again. +
+
+

Token overview

+ + + + Description: {{ token.description }}
+ Expiration date: {{ token.expiration_date | date }} +
+ warning + This token has expired! +
+
+ +
+
+
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..8fc3b028a5 --- /dev/null +++ b/frontend/src/app/general/auth/basic-auth-token/basic-auth-token.component.ts @@ -0,0 +1,56 @@ +/* + * 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; + passwordRevealed = false; + 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('"', ''))); + this.tokenDescription = ''; + } + + deleteToken(token: Token) { + this.tokenService.deleteToken(token).subscribe(); + } + + isTokenExpired(expirationDate: string): boolean { + const expirationDateObj = new Date(expirationDate); + return expirationDateObj < new Date(); + } + + showClipboardMessage(): void { + this.toastService.showSuccess( + 'Token copied', + 'The token 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 + Events + Tokens +