diff --git a/docs/command-line-interface.rst b/docs/command-line-interface.rst index ef2925127c..da6b5fb9e4 100644 --- a/docs/command-line-interface.rst +++ b/docs/command-line-interface.rst @@ -717,21 +717,32 @@ Optional arguments: .. note:: This command is to be used when ScanCode.io's authentication system :ref:`scancodeio_settings_require_authentication` is enabled. -Creates a user and generates an API key for authentication. +Creates a new user and optionally generates an API key for authentication. You will be prompted for a password. After you enter one, the user will be created immediately. -The API key for the new user account will be displayed on the terminal output. - .. code-block:: console - User created with API key: abcdef123456 + $ scanpipe create-user + User created. + +Use the ``--generate-api-key`` option to generate an API key for this user and print it +to the console. + +.. code-block:: console -The API key can also be retrieved from the :guilabel:`Profile settings` menu in the UI. + $ scanpipe create-user --generate-api-key + User created. + API key: 1234567890abcdef .. warning:: - Your API key is like a password and should be treated with the same care. + Treat your API key like a password and keep it secure. + For security reasons, the key is only shown once at generation time. + If you lose it, you will need to regenerate a new one. + +.. tip:: + The API key can be regenerated from the :guilabel:`Profile settings` menu in the UI. By default, this command will prompt for a password for the new user account. When run non-interactively with the ``--no-input`` option, no password will be set, @@ -741,6 +752,7 @@ API key. Optional arguments: - ``--no-input`` Does not prompt the user for input of any kind. +- ``--generate-api-key`` Generate an API key for this user and print it to the console. - ``--admin`` Specifies that the user should be created as an admin user. - ``--super`` Specifies that the user should be created as a superuser. diff --git a/pyproject.toml b/pyproject.toml index bbc5757e56..63f69d3857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ dependencies = [ "aboutcode.hashid==0.2.0", # AboutCode pipeline "aboutcode.pipeline==0.2.1", + "aboutcode.api-auth==0.2.0", # ScoreCode "scorecode==0.0.4" ] diff --git a/scancodeio/settings.py b/scancodeio/settings.py index 4bb3673ec6..9a94a13383 100644 --- a/scancodeio/settings.py +++ b/scancodeio/settings.py @@ -194,7 +194,6 @@ "crispy_bootstrap3", # required for the djangorestframework browsable API "django_filters", "rest_framework", - "rest_framework.authtoken", "django_rq", "django_probes", "taggit", @@ -401,12 +400,12 @@ CLAMD_USE_TCP = env.bool("CLAMD_USE_TCP", default=True) CLAMD_TCP_ADDR = env.str("CLAMD_TCP_ADDR", default="clamav") -# Django restframework +# REST API + +API_TOKEN_MODEL = "scanpipe.APIToken" # noqa: S105 REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework.authentication.TokenAuthentication", - ), + "DEFAULT_AUTHENTICATION_CLASSES": ("aboutcode.api_auth.APITokenAuthentication",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", diff --git a/scancodeio/urls.py b/scancodeio/urls.py index f0e475e173..ecac3d6da8 100644 --- a/scancodeio/urls.py +++ b/scancodeio/urls.py @@ -32,6 +32,8 @@ from scanpipe.api.views import ProjectViewSet from scanpipe.api.views import RunViewSet from scanpipe.views import AccountProfileView +from scanpipe.views import GenerateAPIKeyView +from scanpipe.views import RevokeAPIKeyView api_router = DefaultRouter() api_router.register(r"projects", ProjectViewSet) @@ -45,6 +47,16 @@ name="logout", ), path("accounts/profile/", AccountProfileView.as_view(), name="account_profile"), + path( + "accounts/profile/api_key/generate/", + GenerateAPIKeyView.as_view(), + name="generate_api_key", + ), + path( + "accounts/profile/api_key/revoke/", + RevokeAPIKeyView.as_view(), + name="revoke_api_key", + ), ] diff --git a/scanpipe/management/commands/create-user.py b/scanpipe/management/commands/create-user.py index 8e537209a3..787e6c0ec1 100644 --- a/scanpipe/management/commands/create-user.py +++ b/scanpipe/management/commands/create-user.py @@ -28,7 +28,7 @@ from django.core.management.base import BaseCommand from django.core.management.base import CommandError -from rest_framework.authtoken.models import Token +from scanpipe.models import APIToken class Command(BaseCommand): @@ -43,13 +43,21 @@ def __init__(self, *args, **kwargs): ) def add_arguments(self, parser): - parser.add_argument("username", help="Specifies the username for the user.") + parser.add_argument( + "username", + help=f"Specifies the {self.UserModel.USERNAME_FIELD} for the user.", + ) parser.add_argument( "--no-input", action="store_false", dest="interactive", help="Do not prompt the user for input of any kind.", ) + parser.add_argument( + "--generate-api-key", + action="store_true", + help="Generate an API key for this user and print it to the console.", + ) parser.add_argument( "--admin", action="store_true", @@ -63,9 +71,16 @@ def add_arguments(self, parser): def handle(self, *args, **options): username = options["username"] + generate_api_key = options["generate_api_key"] is_admin = options["admin"] is_superuser = options["super"] + if options["verbosity"] <= 0 and generate_api_key: + raise CommandError( + "Cannot display the API key with verbosity disabled. " + "The key is only shown once at generation time." + ) + error_msg = self._validate_username(username) if error_msg: raise CommandError(error_msg) @@ -75,19 +90,27 @@ def handle(self, *args, **options): password = self.get_password_from_stdin(username) user_kwargs = { - "username": username, + self.UserModel.USERNAME_FIELD: username, "password": password, "is_staff": is_admin or is_superuser, "is_superuser": is_superuser, } - user = self.UserModel._default_manager.create_user(**user_kwargs) - token, _ = Token._default_manager.get_or_create(user=user) if options["verbosity"] > 0: - msg = f"User {username} created with API key: {token.key}" + msg = f"User {username} created." self.stdout.write(msg, self.style.SUCCESS) + if generate_api_key: + plain_api_key = APIToken.create_token(user=user) + self.stdout.write(f"API key: {plain_api_key}", self.style.SUCCESS) + warning_msg = ( + "Treat your API key like a password and keep it secure. " + "For security reasons, the key is only shown once at generation time. " + "If you lose it, you will need to regenerate a new one." + ) + self.stdout.write(warning_msg, self.style.WARNING) + def get_password_from_stdin(self, username): # Validators, such as UserAttributeSimilarityValidator, depends on other user's # fields data for password validation. diff --git a/scanpipe/migrations/0078_apitoken.py b/scanpipe/migrations/0078_apitoken.py new file mode 100644 index 0000000000..aa22986c15 --- /dev/null +++ b/scanpipe/migrations/0078_apitoken.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.3 on 2026-03-09 05:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanpipe', '0077_alter_discoveredpackage_children_packages'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='APIToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key_hash', models.CharField(max_length=128)), + ('prefix', models.CharField(db_index=True, max_length=8, unique=True)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='api_token', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'API Token', + }, + ), + ] diff --git a/scanpipe/migrations/0079_apitoken_data.py b/scanpipe/migrations/0079_apitoken_data.py new file mode 100644 index 0000000000..22443817ef --- /dev/null +++ b/scanpipe/migrations/0079_apitoken_data.py @@ -0,0 +1,60 @@ +# Generated by Django 6.0.3 on 2026-03-10 22:09 + +from django.db import migrations +from django.contrib.auth.hashers import make_password + + +def migrate_api_tokens(apps, schema_editor): + """Migrate existing plain-text DRF tokens to the new hashed APIToken model.""" + APIToken = apps.get_model("scanpipe", "APIToken") + PREFIX_LENGTH = 8 + + with schema_editor.connection.cursor() as cursor: + cursor.execute( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables " + "WHERE table_name = 'authtoken_token')" + ) + table_exists = cursor.fetchone()[0] + + if not table_exists: + return + + cursor.execute("SELECT user_id, key, created FROM authtoken_token") + rows = cursor.fetchall() + if not rows: + return + + tokens_to_create = [ + APIToken( + user_id=user_id, + prefix=key[:PREFIX_LENGTH], + key_hash=make_password(key), + created=created, + ) + for user_id, key, created in rows + ] + migrated_tokens = APIToken.objects.bulk_create(tokens_to_create, ignore_conflicts=True) + if migrated_tokens: + print(f" -> {len(migrated_tokens)} tokens migrated.") + + +def reverse_migrate_api_tokens(apps, schema_editor): + """Reverse migration: remove all migrated tokens.""" + APIToken = apps.get_model("scanpipe", "APIToken") + APIToken.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanpipe', '0078_apitoken'), + ] + + operations = [ + migrations.RunPython(migrate_api_tokens, reverse_migrate_api_tokens), + migrations.RunSQL( + sql="DROP TABLE IF EXISTS authtoken_token", + reverse_sql=migrations.RunSQL.noop, + ), + ] + diff --git a/scanpipe/models.py b/scanpipe/models.py index ac8e2da155..f065fb4bb5 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -57,7 +57,6 @@ from django.db.models import When from django.db.models.functions import Cast from django.db.models.functions import Lower -from django.dispatch import receiver from django.forms import model_to_dict from django.urls import NoReverseMatch from django.urls import reverse @@ -70,6 +69,7 @@ import redis import requests import saneyaml +from aboutcode.api_auth import AbstractAPIToken from commoncode.fileutils import parent_directory from cyclonedx import model as cyclonedx_model from cyclonedx.model import component as cyclonedx_component @@ -86,7 +86,6 @@ from packageurl.contrib.django.models import PACKAGE_URL_FIELDS from packageurl.contrib.django.models import PackageURLMixin from packageurl.contrib.django.models import PackageURLQuerySetMixin -from rest_framework.authtoken.models import Token from rq.command import send_stop_job_command from rq.exceptions import NoSuchJobError from rq.job import Job @@ -146,6 +145,11 @@ class Meta: abstract = True +class APIToken(AbstractAPIToken): + class Meta: + verbose_name = "API Token" + + class HashFieldsMixin(models.Model): """ The hash fields are not indexed by default, use the `indexes` in Meta as needed: @@ -4963,13 +4967,6 @@ def success(self): return self.response_status_code in (200, 201, 202) -@receiver(models.signals.post_save, sender=settings.AUTH_USER_MODEL) -def create_auth_token(sender, instance=None, created=False, **kwargs): - """Create an API key token on user creation, using the signal system.""" - if created: - Token.objects.create(user_id=instance.pk) - - class DiscoveredPackageScore(UUIDPKModel, PackageScoreMixin): """Represents a security or quality score for a DiscoveredPackage.""" diff --git a/scanpipe/templates/account/profile.html b/scanpipe/templates/account/profile.html index ec96eac82d..f71a732cd1 100644 --- a/scanpipe/templates/account/profile.html +++ b/scanpipe/templates/account/profile.html @@ -13,23 +13,46 @@ - -
-
- An API key is like a password and should be treated with the same care. -
-
- -
- -
- - - - +
+
+
+
+ Your personal API key provides access to the + REST API +
+ Treat it like a password and keep it secure. +
+
+ {% if request.user.api_token %} +
+ Your API key {{ request.user.api_token.prefix }}... + was generated on {{ request.user.api_token.created }}
+ For security reasons, the full key is only shown once at generation time.
+ If you lose it, you will need to regenerate a new one. +
+ {% else %} +
+ No API key created.
+ Generate one using the button below to access the REST API. +
+ {% endif %} +
+
+ + {% if request.user.api_token %} + + {% endif %} +
- + {% include 'scanpipe/modals/profile_generate_api_key_modal.html' %} + {% if request.user.api_token %} + {% include 'scanpipe/modals/profile_revoke_api_key_modal.html' %} + {% endif %}
{% endblock %} \ No newline at end of file diff --git a/scanpipe/templates/scanpipe/includes/navbar_header.html b/scanpipe/templates/scanpipe/includes/navbar_header.html index caa3bc74e7..cb12597fa8 100644 --- a/scanpipe/templates/scanpipe/includes/navbar_header.html +++ b/scanpipe/templates/scanpipe/includes/navbar_header.html @@ -35,14 +35,11 @@ {% else %} diff --git a/scanpipe/templates/scanpipe/modals/profile_generate_api_key_modal.html b/scanpipe/templates/scanpipe/modals/profile_generate_api_key_modal.html new file mode 100644 index 0000000000..b302f25ea2 --- /dev/null +++ b/scanpipe/templates/scanpipe/modals/profile_generate_api_key_modal.html @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/scanpipe/templates/scanpipe/modals/profile_revoke_api_key_modal.html b/scanpipe/templates/scanpipe/modals/profile_revoke_api_key_modal.html new file mode 100644 index 0000000000..6bfa65d118 --- /dev/null +++ b/scanpipe/templates/scanpipe/modals/profile_revoke_api_key_modal.html @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/scanpipe/tests/test_api.py b/scanpipe/tests/test_api.py index 03d68cfaca..64b447427f 100644 --- a/scanpipe/tests/test_api.py +++ b/scanpipe/tests/test_api.py @@ -45,6 +45,7 @@ from scanpipe.api.serializers import ProjectSerializer from scanpipe.api.serializers import get_model_serializer from scanpipe.api.serializers import get_serializer_fields +from scanpipe.models import APIToken from scanpipe.models import CodebaseRelation from scanpipe.models import CodebaseResource from scanpipe.models import DiscoveredDependency @@ -91,7 +92,8 @@ def setUp(self): self.project1_detail_url = reverse("project-detail", args=[self.project1.uuid]) self.user = User.objects.create_user("username", "e@mail.com", "secret") - self.auth = f"Token {self.user.auth_token.key}" + self.user_api_key = APIToken.create_token(user=self.user) + self.auth = f"Token {self.user_api_key}" self.csrf_client = APIClient(enforce_csrf_checks=True) self.csrf_client.credentials(HTTP_AUTHORIZATION=self.auth) diff --git a/scanpipe/tests/test_auth.py b/scanpipe/tests/test_auth.py index 32e178a7d4..2f9213c6a4 100644 --- a/scanpipe/tests/test_auth.py +++ b/scanpipe/tests/test_auth.py @@ -22,6 +22,7 @@ import uuid +from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser @@ -35,6 +36,7 @@ TEST_PASSWORD = str(uuid.uuid4()) +APIToken = apps.get_model("scanpipe", "APIToken") login_url = reverse("login") project_list_url = reverse("project_list") logout_url = reverse("logout") @@ -93,10 +95,10 @@ def test_scancodeio_auth_logged_in_navbar_header(self): response = self.client.get(project_list_url) expected = 'basic_user' self.assertContains(response, expected, html=True) - expected = f'Profile settings' - self.assertContains(response, expected, html=True) expected = f'
' self.assertContains(response, expected) + self.assertContains(response, profile_url) + self.assertContains(response, "Profile settings") def test_scancodeio_auth_logout_view(self): response = self.client.get(logout_url) @@ -111,10 +113,22 @@ def test_scancodeio_auth_logout_view(self): def test_scancodeio_account_profile_view(self): self.client.login(username=self.basic_user.username, password=TEST_PASSWORD) + + expected1 = "No API key created." + expected2 = "Generate API key" + expected3 = "Revoke API key" + response = self.client.get(profile_url) - expected = '' - self.assertContains(response, expected, html=True) - self.assertContains(response, self.basic_user.auth_token.key) + self.assertContains(response, expected1) + self.assertContains(response, expected2) + self.assertNotContains(response, expected3) + + APIToken.create_token(user=self.basic_user) + response = self.client.get(profile_url) + self.assertNotContains(response, expected1) + self.assertContains(response, expected2) + self.assertContains(response, expected3) + self.assertContains(response, self.basic_user.api_token.prefix) def test_scancodeio_auth_views_are_protected(self): a_uuid = uuid.uuid4() diff --git a/scanpipe/tests/test_commands.py b/scanpipe/tests/test_commands.py index 7e93bc1c64..749fc0269b 100644 --- a/scanpipe/tests/test_commands.py +++ b/scanpipe/tests/test_commands.py @@ -31,6 +31,7 @@ from django.apps import apps from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist from django.core.management import CommandError from django.core.management import call_command from django.core.management.base import BaseCommand @@ -928,15 +929,28 @@ def test_scanpipe_management_command_create_user(self): username = "my_username" call_command("create-user", "--no-input", username, stdout=out) - self.assertIn(f"User {username} created with API key:", out.getvalue()) + self.assertIn(f"User {username} created.", out.getvalue()) + self.assertNotIn("API key", out.getvalue()) user = get_user_model().objects.get(username=username) - self.assertTrue(user.auth_token) self.assertFalse(user.is_staff) self.assertFalse(user.is_superuser) + message = "User has no api_token" + with self.assertRaisesMessage(ObjectDoesNotExist, message): + user.api_token + + username = "verbosity_issue" + options = ["--no-input", "--generate-api-key", "--verbosity=0"] + expected = ( + "Cannot display the API key with verbosity disabled. " + "The key is only shown once at generation time." + ) + with self.assertRaisesMessage(CommandError, expected): + call_command("create-user", username, *options) + expected = "Error: That username is already taken." with self.assertRaisesMessage(CommandError, expected): - call_command("create-user", "--no-input", username) + call_command("create-user", "--no-input", user.username) username = "^&*" expected = ( diff --git a/scanpipe/tests/test_models.py b/scanpipe/tests/test_models.py index e4a5b4cb7d..0251e7b729 100644 --- a/scanpipe/tests/test_models.py +++ b/scanpipe/tests/test_models.py @@ -2635,11 +2635,6 @@ def test_scanpipe_discovered_package_model_get_author_names(self): expected = ["Debian X Strike Force", "JBoss.org Community"] self.assertEqual(expected, package1.get_author_names(roles)) - def test_scanpipe_model_create_user_creates_auth_token(self): - basic_user = User.objects.create_user(username="basic_user") - self.assertTrue(basic_user.auth_token.key) - self.assertEqual(40, len(basic_user.auth_token.key)) - def test_scanpipe_discovered_dependency_model_update_from_data(self): DiscoveredPackage.create_from_data(self.project1, package_data1) CodebaseResource.objects.create( diff --git a/scanpipe/views.py b/scanpipe/views.py index 5e657b874b..038a3e8722 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -60,6 +60,8 @@ import saneyaml import xlsxwriter +from aboutcode.api_auth.views import BaseGenerateAPIKeyView +from aboutcode.api_auth.views import BaseRevokeAPIKeyView from django_filters.views import FilterView from django_htmx.http import HttpResponseClientRedirect from licensedcode.spans import Span @@ -2835,3 +2837,18 @@ def get_context_data(self, **kwargs): context["parent_path"] = "/".join(parent_segments) return context + + +class GenerateAPIKeyView(ConditionalLoginRequired, BaseGenerateAPIKeyView): + success_url = reverse_lazy("account_profile") + success_message = ( + "Copy your API key now, it will not be shown again:" + '
'
+        '{plain_key}'
+        "
" + ) + + +class RevokeAPIKeyView(ConditionalLoginRequired, BaseRevokeAPIKeyView): + success_url = reverse_lazy("account_profile") + success_message = "API key revoked."