From 64fa39540aea46b775790d76b546fa945ec635cb Mon Sep 17 00:00:00 2001 From: danielmursa-dev Date: Thu, 19 Dec 2024 11:30:54 +0100 Subject: [PATCH] [#485] tokenauth setup configuration (#494) * [#485] Merge closed branch * [#485] Add SitesConfigurationStep and TokenAuthConfigurationStep in settings * [#485] Black and isort * [#485] Create Step and Model * [#485] Fix requirements * [#485] Fix yaml file * [#485] Update namespace * [#485] Fix tests * [#485] Update generate tokens * [#485] Fix docs * [#485] Add missing field in docs * [#485] Add validate_no_empty for identifier * [#485] Open-api-framework new realease * [#485] Run bin/generate_envvar_docs.sh * [#485] Update fixtures demodata * [#485] Add sites_config_enable in data.yaml --- docker/setup_configuration/data.yaml | 23 ++ docs/installation/config_cli.rst | 22 + requirements/base.in | 1 + requirements/base.txt | 3 +- requirements/ci.txt | 2 +- requirements/dev.txt | 2 +- src/objects/conf/base.py | 3 +- src/objects/fixtures/demodata.json | 3 +- .../setup_configuration/models/token_auth.py | 23 ++ .../setup_configuration/steps/token_auth.py | 75 ++++ .../tests/files/token_auth/invalid_setup.yaml | 3 + .../token_auth/valid_setup_complete.yaml | 20 + .../files/token_auth/valid_setup_default.yaml | 12 + .../tests/test_token_auth_config.py | 387 ++++++++++++++++++ src/objects/tests/commands/__init__.py | 0 src/objects/tests/config/__init__.py | 0 src/objects/token/admin.py | 2 +- ...enauth_identifier_alter_tokenauth_token.py | 74 ++++ src/objects/token/models.py | 18 +- src/objects/token/tests/factories.py | 1 + src/objects/token/tests/test_migrations.py | 88 ++++ src/objects/token/tests/test_validators.py | 54 +++ src/objects/token/validators.py | 20 + 23 files changed, 826 insertions(+), 10 deletions(-) create mode 100644 src/objects/setup_configuration/models/token_auth.py create mode 100644 src/objects/setup_configuration/steps/token_auth.py create mode 100644 src/objects/setup_configuration/tests/files/token_auth/invalid_setup.yaml create mode 100644 src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml create mode 100644 src/objects/setup_configuration/tests/files/token_auth/valid_setup_default.yaml create mode 100644 src/objects/setup_configuration/tests/test_token_auth_config.py delete mode 100644 src/objects/tests/commands/__init__.py delete mode 100644 src/objects/tests/config/__init__.py create mode 100644 src/objects/token/migrations/0017_tokenauth_identifier_alter_tokenauth_token.py create mode 100644 src/objects/token/tests/test_migrations.py create mode 100644 src/objects/token/tests/test_validators.py create mode 100644 src/objects/token/validators.py diff --git a/docker/setup_configuration/data.yaml b/docker/setup_configuration/data.yaml index ed42429d..d5776648 100644 --- a/docker/setup_configuration/data.yaml +++ b/docker/setup_configuration/data.yaml @@ -1,3 +1,10 @@ +sites_config_enable: true +sites_config: + items: + - domain: example.com + name: Example site + + zgw_consumers_config_enable: true zgw_consumers: services: @@ -19,6 +26,7 @@ zgw_consumers: header_key: Authorization header_value: Token ba9d233e95e04c4a8a661a27daffe7c9bd019067 + notifications_config_enable: true notifications_config: notifications_api_service_identifier: notifications-api @@ -26,6 +34,7 @@ notifications_config: notification_delivery_retry_backoff: 2 notification_delivery_retry_backoff_max: 3 + objecttypes_config_enable: true objecttypes: items: @@ -33,6 +42,20 @@ objecttypes: name: Object Type 1 service_identifier: objecttypes-api + +tokenauth_config_enable: true +tokenauth: + items: + - identifier: token-1 + token: 18b2b74ef994314b84021d47b9422e82b685d82f + contact_person: Person 1 + email: person-1@example.com + organization: Organization 1 + application: Application 1 + administration: Administration 1 + is_superuser: true + + oidc_db_config_enable: true oidc_db_config_admin_auth: items: diff --git a/docs/installation/config_cli.rst b/docs/installation/config_cli.rst index c2e8cd49..4445262a 100644 --- a/docs/installation/config_cli.rst +++ b/docs/installation/config_cli.rst @@ -106,6 +106,28 @@ created. An example of a configuration could be seen below: Tokens configuration -------------------- +Create or update the (single) YAML configuration file with your settings: + +.. code-block:: yaml + + ... + tokenauth_config_enable: true + tokenauth: + items: + - identifier: token-1 + token: ba9d233e95e04c4a8a661a27daffe7c9bd019067 + contact_person: Person 1 + email: person-1@example.com + organization: Organization XYZ # optional + application: Application XYZ # optional + administration: Administration XYZ # optional + is_superuser: true # optional + + - identifier: token-2 + token: 7b2b212d9f16d171a70a1d927cdcfbd5ca7a4799 + contact_person: Person 2 + email: person-2@example.com + ... Mozilla-django-oidc-db ---------------------- diff --git a/requirements/base.in b/requirements/base.in index 5f039d59..0dd81d12 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -6,6 +6,7 @@ jsonschema furl # Common ground libraries +django-setup-configuration>=0.5.0 notifications-api-common[setup-configuration] zgw-consumers[setup-configuration] mozilla-django-oidc-db[setup-configuration] diff --git a/requirements/base.txt b/requirements/base.txt index 1afb8d07..47cb62e8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -153,8 +153,9 @@ django-sendfile2==0.7.0 # via django-privates django-sessionprofile==3.0.0 # via open-api-framework -django-setup-configuration==0.4.0 +django-setup-configuration==0.5.0 # via + # -r requirements/base.in # mozilla-django-oidc-db # notifications-api-common # open-api-framework diff --git a/requirements/ci.txt b/requirements/ci.txt index f3a10f61..82456248 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -239,7 +239,7 @@ django-sessionprofile==3.0.0 # via # -r requirements/base.txt # open-api-framework -django-setup-configuration==0.4.0 +django-setup-configuration==0.5.0 # via # -r requirements/base.txt # mozilla-django-oidc-db diff --git a/requirements/dev.txt b/requirements/dev.txt index 0ac9bb4e..8adf6677 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -254,7 +254,7 @@ django-sessionprofile==3.0.0 # via # -r requirements/base.txt # open-api-framework -django-setup-configuration==0.4.0 +django-setup-configuration==0.5.0 # via # -r requirements/base.txt # mozilla-django-oidc-db diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index 0b41fc04..f7ab7c12 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -84,8 +84,9 @@ # Django setup configuration # SETUP_CONFIGURATION_STEPS = ( + "django_setup_configuration.contrib.sites.steps.SitesConfigurationStep", "zgw_consumers.contrib.setup_configuration.steps.ServiceConfigurationStep", "notifications_api_common.contrib.setup_configuration.steps.NotificationConfigurationStep", - "objects.setup_configuration.steps.objecttypes.ObjectTypesConfigurationStep", "mozilla_django_oidc_db.setup_configuration.steps.AdminOIDCConfigurationStep", + "objects.setup_configuration.steps.token_auth.TokenAuthConfigurationStep", ) diff --git a/src/objects/fixtures/demodata.json b/src/objects/fixtures/demodata.json index c832bf5b..1a7668d4 100644 --- a/src/objects/fixtures/demodata.json +++ b/src/objects/fixtures/demodata.json @@ -779,7 +779,8 @@ "organization": "", "last_modified": "2020-12-23T11:43:16.820Z", "created": "2020-12-22T16:27:00.751Z", - "token": "cd63e158f3aca276ef284e3033d020a22899c728" + "token": "cd63e158f3aca276ef284e3033d020a22899c728", + "identifier": "token-1" } }, { diff --git a/src/objects/setup_configuration/models/token_auth.py b/src/objects/setup_configuration/models/token_auth.py new file mode 100644 index 00000000..5adf5746 --- /dev/null +++ b/src/objects/setup_configuration/models/token_auth.py @@ -0,0 +1,23 @@ +from django_setup_configuration.models import ConfigurationModel + +from objects.token.models import TokenAuth + + +class TokenAuthConfigurationModel(ConfigurationModel): + class Meta: + django_model_refs = { + TokenAuth: ( + "identifier", + "token", + "contact_person", + "email", + "organization", + "application", + "administration", + "is_superuser", + ) + } + + +class TokenAuthGroupConfigurationModel(ConfigurationModel): + items: list[TokenAuthConfigurationModel] diff --git a/src/objects/setup_configuration/steps/token_auth.py b/src/objects/setup_configuration/steps/token_auth.py new file mode 100644 index 00000000..09577370 --- /dev/null +++ b/src/objects/setup_configuration/steps/token_auth.py @@ -0,0 +1,75 @@ +import logging + +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from objects.setup_configuration.models.token_auth import ( + TokenAuthGroupConfigurationModel, +) +from objects.token.models import TokenAuth + +logger = logging.getLogger(__name__) + + +class TokenAuthConfigurationStep( + BaseConfigurationStep[TokenAuthGroupConfigurationModel] +): + """ + Configure tokens for other applications to access Objects API + """ + + namespace = "tokenauth" + enable_setting = "tokenauth_config_enable" + + verbose_name = "Configuration to set up authentication tokens for objects" + config_model = TokenAuthGroupConfigurationModel + + def execute(self, model: TokenAuthGroupConfigurationModel) -> None: + if len(model.items) == 0: + logger.warning("No tokens provided for configuration") + + for item in model.items: + logger.info(f"Configuring {item.identifier}") + + model_kwargs = { + "identifier": item.identifier, + "token": item.token, + "contact_person": item.contact_person, + "email": item.email, + "organization": item.organization, + "application": item.application, + "administration": item.administration, + "is_superuser": item.is_superuser, + } + + token_instance = TokenAuth(**model_kwargs) + + try: + token_instance.full_clean(exclude=("id",), validate_unique=False) + except ValidationError as exception: + exception_message = ( + f"Validation error(s) occured for {item.identifier}." + ) + raise ConfigurationRunFailed(exception_message) from exception + + logger.debug(f"No validation errors found for {item.identifier}") + + try: + logger.debug(f"Saving {item.identifier}") + + TokenAuth.objects.update_or_create( + identifier=item.identifier, + defaults={ + key: value + for key, value in model_kwargs.items() + if key != "identifier" + }, + ) + except IntegrityError as exception: + exception_message = f"Failed configuring token {item.identifier}." + raise ConfigurationRunFailed(exception_message) from exception + + logger.info(f"Configured {item.identifier}") diff --git a/src/objects/setup_configuration/tests/files/token_auth/invalid_setup.yaml b/src/objects/setup_configuration/tests/files/token_auth/invalid_setup.yaml new file mode 100644 index 00000000..c4481cdc --- /dev/null +++ b/src/objects/setup_configuration/tests/files/token_auth/invalid_setup.yaml @@ -0,0 +1,3 @@ +tokenauth_config_enable: true +tokenauth: + items: \ No newline at end of file diff --git a/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml b/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml new file mode 100644 index 00000000..6252d846 --- /dev/null +++ b/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml @@ -0,0 +1,20 @@ +tokenauth_config_enable: true +tokenauth: + items: + - identifier: token-1 + token: 18b2b74ef994314b84021d47b9422e82b685d82f + contact_person: Person 1 + email: person-1@example.com + organization: Organization 1 + application: Application 1 + administration: Administration 1 + is_superuser: True + + - identifier: token-2 + token: e882642bd0ec2482adcdc97258c2e6f98cb06d85 + contact_person: Person 2 + email: person-2@example.com + organization: Organization 2 + application: Application 2 + administration: Administration 2 + is_superuser: True diff --git a/src/objects/setup_configuration/tests/files/token_auth/valid_setup_default.yaml b/src/objects/setup_configuration/tests/files/token_auth/valid_setup_default.yaml new file mode 100644 index 00000000..40d4c9ad --- /dev/null +++ b/src/objects/setup_configuration/tests/files/token_auth/valid_setup_default.yaml @@ -0,0 +1,12 @@ +tokenauth_config_enable: true +tokenauth: + items: + - identifier: token-1 + token: 18b2b74ef994314b84021d47b9422e82b685d82f + contact_person: Person 1 + email: person-1@example.com + + - identifier: token-2 + token: e882642bd0ec2482adcdc97258c2e6f98cb06d85 + contact_person: Person 2 + email: person-2@example.com diff --git a/src/objects/setup_configuration/tests/test_token_auth_config.py b/src/objects/setup_configuration/tests/test_token_auth_config.py new file mode 100644 index 00000000..a7c8676e --- /dev/null +++ b/src/objects/setup_configuration/tests/test_token_auth_config.py @@ -0,0 +1,387 @@ +from pathlib import Path + +from django.test import TestCase + +from django_setup_configuration.exceptions import ( + ConfigurationRunFailed, + PrerequisiteFailed, +) +from django_setup_configuration.test_utils import execute_single_step + +from objects.setup_configuration.steps.token_auth import TokenAuthConfigurationStep +from objects.token.models import TokenAuth +from objects.token.tests.factories import TokenAuthFactory + +DIR_FILES = (Path(__file__).parent / "files/token_auth").resolve() + + +class TokenAuthConfigurationStepTests(TestCase): + def test_valid_setup_default(self): + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_default.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + token = tokens.get(identifier="token-1") + self.assertEqual(token.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(token.contact_person, "Person 1") + self.assertEqual(token.email, "person-1@example.com") + self.assertEqual(token.organization, "") + self.assertEqual(token.application, "") + self.assertEqual(token.administration, "") + self.assertFalse(token.is_superuser) + + token = tokens.get(identifier="token-2") + self.assertEqual(token.contact_person, "Person 2") + self.assertEqual(token.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(token.email, "person-2@example.com") + self.assertEqual(token.organization, "") + self.assertEqual(token.application, "") + self.assertEqual(token.administration, "") + self.assertFalse(token.is_superuser) + + def test_valid_setup_complete(self): + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + # Same as configuration + token = tokens.get(identifier="token-1") + self.assertEqual(token.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(token.contact_person, "Person 1") + self.assertEqual(token.email, "person-1@example.com") + self.assertEqual(token.organization, "Organization 1") + self.assertEqual(token.application, "Application 1") + self.assertEqual(token.administration, "Administration 1") + self.assertTrue(token.is_superuser) + + # Token data updated + token = tokens.get(identifier="token-2") + self.assertEqual(token.contact_person, "Person 2") + self.assertEqual(token.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(token.email, "person-2@example.com") + self.assertEqual(token.organization, "Organization 2") + self.assertEqual(token.application, "Application 2") + self.assertEqual(token.administration, "Administration 2") + self.assertTrue(token.is_superuser) + + def test_valid_update_existing_tokens(self): + TokenAuthFactory( + identifier="token-1", + token="18b2b74ef994314b84021d47b9422e82b685d82f", + contact_person="Person 1", + email="person-1@example.com", + organization="Organization XYZ", + application="Application XYZ", + administration="Administration XYZ", + ) + + TokenAuthFactory( + identifier="token-2", + token="1cad42916dfa439af8c69000bf7b6af6a66782af", + contact_person="Person 3", + email="person-3@example.com", + ) + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + # Same as configuration + token = tokens.get(identifier="token-1") + self.assertEqual(token.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(token.contact_person, "Person 1") + self.assertEqual(token.email, "person-1@example.com") + self.assertEqual(token.organization, "Organization 1") + self.assertEqual(token.application, "Application 1") + self.assertEqual(token.administration, "Administration 1") + self.assertTrue(token.is_superuser) + + # Token data updated + token = tokens.get(identifier="token-2") + self.assertEqual(token.contact_person, "Person 2") + self.assertEqual(token.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(token.email, "person-2@example.com") + self.assertEqual(token.organization, "Organization 2") + self.assertEqual(token.application, "Application 2") + self.assertEqual(token.administration, "Administration 2") + self.assertTrue(token.is_superuser) + + self.assertNotEqual(token.token, "1cad42916dfa439af8c69000bf7b6af6a66782af") + self.assertNotEqual(token.contact_person, "Person 3") + self.assertNotEqual(token.email, "person-3@example.com") + + def test_valid_idempotent_step(self): + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + old_token_a = tokens.get(identifier="token-1") + self.assertEqual(old_token_a.identifier, "token-1") + self.assertEqual(old_token_a.token, "18b2b74ef994314b84021d47b9422e82b685d82f") + self.assertEqual(old_token_a.contact_person, "Person 1") + self.assertEqual(old_token_a.email, "person-1@example.com") + self.assertEqual(old_token_a.organization, "Organization 1") + self.assertEqual(old_token_a.application, "Application 1") + self.assertEqual(old_token_a.administration, "Administration 1") + self.assertTrue(old_token_a.is_superuser) + + old_token_b = tokens.get(identifier="token-2") + self.assertEqual(old_token_b.identifier, "token-2") + self.assertEqual(old_token_b.contact_person, "Person 2") + self.assertEqual(old_token_b.token, "e882642bd0ec2482adcdc97258c2e6f98cb06d85") + self.assertEqual(old_token_b.email, "person-2@example.com") + self.assertEqual(old_token_b.organization, "Organization 2") + self.assertEqual(old_token_b.application, "Application 2") + self.assertEqual(old_token_b.administration, "Administration 2") + self.assertTrue(old_token_b.is_superuser) + + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "valid_setup_complete.yaml"), + ) + + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + new_token_a = tokens.get(identifier="token-1") + self.assertEqual(new_token_a.identifier, old_token_a.identifier) + self.assertEqual(new_token_a.token, old_token_a.token) + self.assertEqual(new_token_a.contact_person, old_token_a.contact_person) + self.assertEqual(new_token_a.email, old_token_a.email) + self.assertEqual(new_token_a.organization, old_token_a.organization) + self.assertEqual(new_token_a.application, old_token_a.application) + self.assertEqual(new_token_a.administration, old_token_a.administration) + + new_token_b = tokens.get(identifier="token-2") + self.assertEqual(new_token_b.identifier, old_token_b.identifier) + self.assertEqual(new_token_b.contact_person, old_token_b.contact_person) + self.assertEqual(new_token_b.token, old_token_b.token) + self.assertEqual(new_token_b.email, old_token_b.email) + self.assertEqual(new_token_b.organization, old_token_b.organization) + self.assertEqual(new_token_b.application, old_token_b.application) + self.assertEqual(new_token_b.administration, old_token_b.administration) + + def test_invalid_setup(self): + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step( + TokenAuthConfigurationStep, + yaml_source=str(DIR_FILES / "invalid_setup.yaml"), + ) + + self.assertTrue("Input should be a valid list" in str(command_error.exception)) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_email(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "Person 1", + "email": "invalid", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_token(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "invalid token", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_empty_token(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_token_missing(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue("Field required" in str(command_error.exception)) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_token_unique(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "contact_person": "Person 1", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + { + "identifier": "token-2", + "contact_person": "Person 2", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "email": "person-2@example.com", + "organization": "Organization 2", + "application": "Application 2", + "administration": "Administration 2", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Failed configuring token token-2" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 1) + + def test_invalid_setup_contact_person(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "token-1", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(ConfigurationRunFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + + self.assertTrue( + "Validation error(s) occured for token-1" in str(command_error.exception) + ) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_setup_identifier(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "invalid identifier", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + self.assertTrue("String should match pattern" in str(command_error.exception)) + self.assertEqual(TokenAuth.objects.count(), 0) + + def test_invalid_empty_identifier(self): + object_source = { + "tokenauth_config_enable": True, + "tokenauth": { + "items": [ + { + "identifier": "", + "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", + "contact_person": "Person 1", + "email": "person-1@example.com", + "organization": "Organization 1", + "application": "Application 1", + "administration": "Administration 1", + }, + ], + }, + } + with self.assertRaises(PrerequisiteFailed) as command_error: + execute_single_step(TokenAuthConfigurationStep, object_source=object_source) + self.assertTrue("String should match pattern" in str(command_error.exception)) + self.assertEqual(TokenAuth.objects.count(), 0) diff --git a/src/objects/tests/commands/__init__.py b/src/objects/tests/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/objects/tests/config/__init__.py b/src/objects/tests/config/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/objects/token/admin.py b/src/objects/token/admin.py index 3d7d82b6..7898b30d 100644 --- a/src/objects/token/admin.py +++ b/src/objects/token/admin.py @@ -162,7 +162,7 @@ def get_uuid(self, obj): @admin.register(TokenAuth) class TokenAuthAdmin(admin.ModelAdmin): list_display = ( - "token", + "identifier", "contact_person", "organization", "administration", diff --git a/src/objects/token/migrations/0017_tokenauth_identifier_alter_tokenauth_token.py b/src/objects/token/migrations/0017_tokenauth_identifier_alter_tokenauth_token.py new file mode 100644 index 00000000..544dc073 --- /dev/null +++ b/src/objects/token/migrations/0017_tokenauth_identifier_alter_tokenauth_token.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.15 on 2024-12-11 10:07 +import logging + +from django.db import migrations, models +from django.db.migrations.state import StateApps + +import objects.token.validators + +logger = logging.getLogger(__name__) + + +def _generate_unique_identifiers(apps: StateApps, schema_editor) -> None: + TokenAuth = apps.get_model("token", "TokenAuth") + + count = 1 + + for token in TokenAuth.objects.filter(identifier__isnull=True): + while TokenAuth.objects.filter(identifier=f"token-{count}").exists(): + count += 1 + + identifier = f"token-{count}" + logger.debug(f"Generated {identifier} for token {token.pk}") + + token.identifier = identifier + token.save(update_fields=("identifier",)) + + +class Migration(migrations.Migration): + + dependencies = [ + ("token", "0016_alter_permission_token_auth"), + ] + + operations = [ + migrations.AddField( + model_name="tokenauth", + name="identifier", + field=models.CharField( + blank=True, + null=True, + ), + ), + migrations.RunPython( + code=_generate_unique_identifiers, + reverse_code=migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="tokenauth", + name="identifier", + field=models.SlugField(unique=True), + ), + migrations.AlterField( + model_name="tokenauth", + name="identifier", + field=models.SlugField( + help_text="A human-friendly label to refer to this token", + unique=True, + validators=[objects.token.validators.validate_no_empty], + ), + ), + migrations.AlterField( + model_name="tokenauth", + name="token", + field=models.CharField( + max_length=40, + unique=True, + validators=[ + objects.token.validators.validate_no_empty, + objects.token.validators.validate_no_whitespace, + ], + verbose_name="token", + ), + ), + ] diff --git a/src/objects/token/models.py b/src/objects/token/models.py index a11a7b2e..4289f22d 100644 --- a/src/objects/token/models.py +++ b/src/objects/token/models.py @@ -1,17 +1,27 @@ -import binascii -import os +import secrets from django.core import exceptions from django.db import models from django.utils.translation import gettext_lazy as _ from objects.core.models import ObjectType +from objects.token.validators import validate_no_empty, validate_no_whitespace from .constants import PermissionModes class TokenAuth(models.Model): - token = models.CharField(_("token"), max_length=40, unique=True) + identifier = models.SlugField( + unique=True, + help_text=_("A human-friendly label to refer to this token"), + validators=[validate_no_empty], + ) + token = models.CharField( + _("token"), + max_length=40, + unique=True, + validators=[validate_no_empty, validate_no_whitespace], + ) contact_person = models.CharField( _("contact person"), max_length=200, @@ -71,7 +81,7 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) def generate_token(self): - return binascii.hexlify(os.urandom(20)).decode() + return secrets.token_hex(20) def get_permission_for_object_type(self, object_type: ObjectType): if not self.permissions.filter(object_type=object_type).exists(): diff --git a/src/objects/token/tests/factories.py b/src/objects/token/tests/factories.py index 35246763..0baf0d32 100644 --- a/src/objects/token/tests/factories.py +++ b/src/objects/token/tests/factories.py @@ -7,6 +7,7 @@ class TokenAuthFactory(factory.django.DjangoModelFactory): + identifier = factory.Sequence(lambda sequence: f"token-{sequence}") contact_person = factory.Faker("name") email = factory.Faker("email") diff --git a/src/objects/token/tests/test_migrations.py b/src/objects/token/tests/test_migrations.py new file mode 100644 index 00000000..f4834931 --- /dev/null +++ b/src/objects/token/tests/test_migrations.py @@ -0,0 +1,88 @@ +from django.core.management import call_command +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.db.migrations.state import StateApps +from django.test import TransactionTestCase + + +class BaseMigrationTest(TransactionTestCase): + app: str + migrate_from: str # The migration before the one we want to test + migrate_to: str # The migration we want to test + + setting_overrides: dict = {} + + old_app_state: StateApps + app_state: StateApps + + def setUp(self) -> None: + """ + Setup the migration test by reversing to `migrate_from` state, + then applying the `migrate_to` state. + """ + assert self.app is not None, "You must define the `app` attribute" + assert self.migrate_from is not None, "You must define `migrate_from`" + assert self.migrate_to is not None, "You must define `migrate_to`" + + # Step 1: Set up the MigrationExecutor + executor = MigrationExecutor(connection) + + # Step 2: Reverse to the starting migration state + migrate_from = [(self.app, self.migrate_from)] + old_migrate_state = executor.migrate(migrate_from) + + self.old_app_state = old_migrate_state.apps + + def _perform_migration(self) -> None: + migrate_to = [(self.app, self.migrate_to)] + + executor = MigrationExecutor(connection) + executor.loader.build_graph() # reload the graph in case of dependency changes + executor.migrate(migrate_to) + + self.apps = executor.loader.project_state(migrate_to).apps + + @classmethod + def tearDownClass(cls) -> None: + super().tearDownClass() + + # reset to latest migration + call_command("migrate", verbosity=0, database=connection._alias) + + +class TestTokenAuthUniqueness(BaseMigrationTest): + app = "token" + migrate_from = "0016_alter_permission_token_auth" + migrate_to = "0017_tokenauth_identifier_alter_tokenauth_token" + + def test_migrate_tokens_check_attr(self): + TokenAuth = self.old_app_state.get_model("token", "TokenAuth") + self.assertFalse(hasattr(TokenAuth, "identifier")) + + self._perform_migration() + + TokenAuth = self.apps.get_model("token", "TokenAuth") + self.assertTrue(hasattr(TokenAuth, "identifier")) + + def test_migrate_tokens_to_unique_identifiers(self): + TokenAuth = self.old_app_state.get_model("token", "TokenAuth") + TokenAuth.objects.create( + token="aa018d1c576c9dae33be1e549f739f2834ebc811", + contact_person="Person 1", + email="test@example.com", + ) + TokenAuth.objects.create( + token="ab700d6bf906c2b4b42a961c529657314c6a8246", + contact_person="Other person", + email="somebody@else.com", + ) + + self._perform_migration() + + TokenAuth = self.apps.get_model("token", "TokenAuth") + tokens = TokenAuth.objects.all() + self.assertEqual(tokens.count(), 2) + + first_token = tokens.get(token="aa018d1c576c9dae33be1e549f739f2834ebc811") + second_token = tokens.get(token="ab700d6bf906c2b4b42a961c529657314c6a8246") + self.assertNotEqual(first_token.identifier, second_token.identifier) diff --git a/src/objects/token/tests/test_validators.py b/src/objects/token/tests/test_validators.py new file mode 100644 index 00000000..62e5d1ab --- /dev/null +++ b/src/objects/token/tests/test_validators.py @@ -0,0 +1,54 @@ +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase + +from objects.token.validators import validate_no_empty, validate_no_whitespace + + +class NoEmptyValidatorTestCase(SimpleTestCase): + def test_valid(self): + self.assertIsNone(validate_no_empty("test123")) + + def test_invalid_string(self): + with self.assertRaises(ValidationError): + validate_no_empty("") + + def test_invalid_none(self): + with self.assertRaises(ValidationError): + validate_no_empty(None) + + +class WhiteSpaceValidatorTestCase(SimpleTestCase): + def test_characters_only(self): + self.assertIsNone(validate_no_whitespace("test123")) + + def test_trailing_whitespace(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test123 ") + + def test_leading_whitespace(self): + with self.assertRaises(ValidationError): + validate_no_whitespace(" test123") + + def test_whitespace_in_between(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test 123") + + def test_whitespace_only(self): + with self.assertRaises(ValidationError): + validate_no_whitespace(" ") + + def test_trailing_tab_character(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test123\t") + + def test_leading_tab_character(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("\ttest123") + + def test_tab_character_in_between(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("test\t123") + + def test_tab_characters_only(self): + with self.assertRaises(ValidationError): + validate_no_whitespace("\t\t") diff --git a/src/objects/token/validators.py b/src/objects/token/validators.py new file mode 100644 index 00000000..21d23277 --- /dev/null +++ b/src/objects/token/validators.py @@ -0,0 +1,20 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +# includes tabs, carriage returns, newlines, form-feeds and vertical whitespace characters +WHITESPACE_PATTERN = re.compile(r".*\s.*") + + +def validate_no_whitespace(value: str) -> None: + if WHITESPACE_PATTERN.match(value): + raise ValidationError( + code="all-whitespace", + message=_("Tokens cannot contain whitespace-like characters"), + ) + + +def validate_no_empty(value: str) -> None: + if not value: + raise ValidationError(code="invalid", message=_("Blank values are not allowed"))