Skip to content

Commit 79d7927

Browse files
authored
feat!: replace plain-text DRF token with PBKDF2-hashed API token (#2087)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 75d9b80 commit 79d7927

17 files changed

Lines changed: 320 additions & 61 deletions

docs/command-line-interface.rst

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -717,21 +717,32 @@ Optional arguments:
717717
.. note:: This command is to be used when ScanCode.io's authentication system
718718
:ref:`scancodeio_settings_require_authentication` is enabled.
719719

720-
Creates a user and generates an API key for authentication.
720+
Creates a new user and optionally generates an API key for authentication.
721721

722722
You will be prompted for a password. After you enter one, the user will be created
723723
immediately.
724724

725-
The API key for the new user account will be displayed on the terminal output.
726-
727725
.. code-block:: console
728726
729-
User <username> created with API key: abcdef123456
727+
$ scanpipe create-user <username>
728+
User <username> created.
729+
730+
Use the ``--generate-api-key`` option to generate an API key for this user and print it
731+
to the console.
732+
733+
.. code-block:: console
730734
731-
The API key can also be retrieved from the :guilabel:`Profile settings` menu in the UI.
735+
$ scanpipe create-user <username> --generate-api-key
736+
User <username> created.
737+
API key: 1234567890abcdef
732738
733739
.. warning::
734-
Your API key is like a password and should be treated with the same care.
740+
Treat your API key like a password and keep it secure.
741+
For security reasons, the key is only shown once at generation time.
742+
If you lose it, you will need to regenerate a new one.
743+
744+
.. tip::
745+
The API key can be regenerated from the :guilabel:`Profile settings` menu in the UI.
735746

736747
By default, this command will prompt for a password for the new user account.
737748
When run non-interactively with the ``--no-input`` option, no password will be set,
@@ -741,6 +752,7 @@ API key.
741752
Optional arguments:
742753

743754
- ``--no-input`` Does not prompt the user for input of any kind.
755+
- ``--generate-api-key`` Generate an API key for this user and print it to the console.
744756
- ``--admin`` Specifies that the user should be created as an admin user.
745757
- ``--super`` Specifies that the user should be created as a superuser.
746758

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ dependencies = [
9696
"aboutcode.hashid==0.2.0",
9797
# AboutCode pipeline
9898
"aboutcode.pipeline==0.2.1",
99+
"aboutcode.api-auth==0.2.0",
99100
# ScoreCode
100101
"scorecode==0.0.4"
101102
]

scancodeio/settings.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,6 @@
194194
"crispy_bootstrap3", # required for the djangorestframework browsable API
195195
"django_filters",
196196
"rest_framework",
197-
"rest_framework.authtoken",
198197
"django_rq",
199198
"django_probes",
200199
"taggit",
@@ -401,12 +400,12 @@
401400
CLAMD_USE_TCP = env.bool("CLAMD_USE_TCP", default=True)
402401
CLAMD_TCP_ADDR = env.str("CLAMD_TCP_ADDR", default="clamav")
403402

404-
# Django restframework
403+
# REST API
404+
405+
API_TOKEN_MODEL = "scanpipe.APIToken" # noqa: S105
405406

406407
REST_FRAMEWORK = {
407-
"DEFAULT_AUTHENTICATION_CLASSES": (
408-
"rest_framework.authentication.TokenAuthentication",
409-
),
408+
"DEFAULT_AUTHENTICATION_CLASSES": ("aboutcode.api_auth.APITokenAuthentication",),
410409
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
411410
"DEFAULT_RENDERER_CLASSES": (
412411
"rest_framework.renderers.JSONRenderer",

scancodeio/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
from scanpipe.api.views import ProjectViewSet
3333
from scanpipe.api.views import RunViewSet
3434
from scanpipe.views import AccountProfileView
35+
from scanpipe.views import GenerateAPIKeyView
36+
from scanpipe.views import RevokeAPIKeyView
3537

3638
api_router = DefaultRouter()
3739
api_router.register(r"projects", ProjectViewSet)
@@ -45,6 +47,16 @@
4547
name="logout",
4648
),
4749
path("accounts/profile/", AccountProfileView.as_view(), name="account_profile"),
50+
path(
51+
"accounts/profile/api_key/generate/",
52+
GenerateAPIKeyView.as_view(),
53+
name="generate_api_key",
54+
),
55+
path(
56+
"accounts/profile/api_key/revoke/",
57+
RevokeAPIKeyView.as_view(),
58+
name="revoke_api_key",
59+
),
4860
]
4961

5062

scanpipe/management/commands/create-user.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from django.core.management.base import BaseCommand
2929
from django.core.management.base import CommandError
3030

31-
from rest_framework.authtoken.models import Token
31+
from scanpipe.models import APIToken
3232

3333

3434
class Command(BaseCommand):
@@ -43,13 +43,21 @@ def __init__(self, *args, **kwargs):
4343
)
4444

4545
def add_arguments(self, parser):
46-
parser.add_argument("username", help="Specifies the username for the user.")
46+
parser.add_argument(
47+
"username",
48+
help=f"Specifies the {self.UserModel.USERNAME_FIELD} for the user.",
49+
)
4750
parser.add_argument(
4851
"--no-input",
4952
action="store_false",
5053
dest="interactive",
5154
help="Do not prompt the user for input of any kind.",
5255
)
56+
parser.add_argument(
57+
"--generate-api-key",
58+
action="store_true",
59+
help="Generate an API key for this user and print it to the console.",
60+
)
5361
parser.add_argument(
5462
"--admin",
5563
action="store_true",
@@ -63,9 +71,16 @@ def add_arguments(self, parser):
6371

6472
def handle(self, *args, **options):
6573
username = options["username"]
74+
generate_api_key = options["generate_api_key"]
6675
is_admin = options["admin"]
6776
is_superuser = options["super"]
6877

78+
if options["verbosity"] <= 0 and generate_api_key:
79+
raise CommandError(
80+
"Cannot display the API key with verbosity disabled. "
81+
"The key is only shown once at generation time."
82+
)
83+
6984
error_msg = self._validate_username(username)
7085
if error_msg:
7186
raise CommandError(error_msg)
@@ -75,19 +90,27 @@ def handle(self, *args, **options):
7590
password = self.get_password_from_stdin(username)
7691

7792
user_kwargs = {
78-
"username": username,
93+
self.UserModel.USERNAME_FIELD: username,
7994
"password": password,
8095
"is_staff": is_admin or is_superuser,
8196
"is_superuser": is_superuser,
8297
}
83-
8498
user = self.UserModel._default_manager.create_user(**user_kwargs)
85-
token, _ = Token._default_manager.get_or_create(user=user)
8699

87100
if options["verbosity"] > 0:
88-
msg = f"User {username} created with API key: {token.key}"
101+
msg = f"User {username} created."
89102
self.stdout.write(msg, self.style.SUCCESS)
90103

104+
if generate_api_key:
105+
plain_api_key = APIToken.create_token(user=user)
106+
self.stdout.write(f"API key: {plain_api_key}", self.style.SUCCESS)
107+
warning_msg = (
108+
"Treat your API key like a password and keep it secure. "
109+
"For security reasons, the key is only shown once at generation time. "
110+
"If you lose it, you will need to regenerate a new one."
111+
)
112+
self.stdout.write(warning_msg, self.style.WARNING)
113+
91114
def get_password_from_stdin(self, username):
92115
# Validators, such as UserAttributeSimilarityValidator, depends on other user's
93116
# fields data for password validation.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 6.0.3 on 2026-03-09 05:23
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('scanpipe', '0077_alter_discoveredpackage_children_packages'),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='APIToken',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('key_hash', models.CharField(max_length=128)),
21+
('prefix', models.CharField(db_index=True, max_length=8, unique=True)),
22+
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
23+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='api_token', to=settings.AUTH_USER_MODEL)),
24+
],
25+
options={
26+
'verbose_name': 'API Token',
27+
},
28+
),
29+
]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Generated by Django 6.0.3 on 2026-03-10 22:09
2+
3+
from django.db import migrations
4+
from django.contrib.auth.hashers import make_password
5+
6+
7+
def migrate_api_tokens(apps, schema_editor):
8+
"""Migrate existing plain-text DRF tokens to the new hashed APIToken model."""
9+
APIToken = apps.get_model("scanpipe", "APIToken")
10+
PREFIX_LENGTH = 8
11+
12+
with schema_editor.connection.cursor() as cursor:
13+
cursor.execute(
14+
"SELECT EXISTS (SELECT 1 FROM information_schema.tables "
15+
"WHERE table_name = 'authtoken_token')"
16+
)
17+
table_exists = cursor.fetchone()[0]
18+
19+
if not table_exists:
20+
return
21+
22+
cursor.execute("SELECT user_id, key, created FROM authtoken_token")
23+
rows = cursor.fetchall()
24+
if not rows:
25+
return
26+
27+
tokens_to_create = [
28+
APIToken(
29+
user_id=user_id,
30+
prefix=key[:PREFIX_LENGTH],
31+
key_hash=make_password(key),
32+
created=created,
33+
)
34+
for user_id, key, created in rows
35+
]
36+
migrated_tokens = APIToken.objects.bulk_create(tokens_to_create, ignore_conflicts=True)
37+
if migrated_tokens:
38+
print(f" -> {len(migrated_tokens)} tokens migrated.")
39+
40+
41+
def reverse_migrate_api_tokens(apps, schema_editor):
42+
"""Reverse migration: remove all migrated tokens."""
43+
APIToken = apps.get_model("scanpipe", "APIToken")
44+
APIToken.objects.all().delete()
45+
46+
47+
class Migration(migrations.Migration):
48+
49+
dependencies = [
50+
('scanpipe', '0078_apitoken'),
51+
]
52+
53+
operations = [
54+
migrations.RunPython(migrate_api_tokens, reverse_migrate_api_tokens),
55+
migrations.RunSQL(
56+
sql="DROP TABLE IF EXISTS authtoken_token",
57+
reverse_sql=migrations.RunSQL.noop,
58+
),
59+
]
60+

scanpipe/models.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757
from django.db.models import When
5858
from django.db.models.functions import Cast
5959
from django.db.models.functions import Lower
60-
from django.dispatch import receiver
6160
from django.forms import model_to_dict
6261
from django.urls import NoReverseMatch
6362
from django.urls import reverse
@@ -70,6 +69,7 @@
7069
import redis
7170
import requests
7271
import saneyaml
72+
from aboutcode.api_auth import AbstractAPIToken
7373
from commoncode.fileutils import parent_directory
7474
from cyclonedx import model as cyclonedx_model
7575
from cyclonedx.model import component as cyclonedx_component
@@ -86,7 +86,6 @@
8686
from packageurl.contrib.django.models import PACKAGE_URL_FIELDS
8787
from packageurl.contrib.django.models import PackageURLMixin
8888
from packageurl.contrib.django.models import PackageURLQuerySetMixin
89-
from rest_framework.authtoken.models import Token
9089
from rq.command import send_stop_job_command
9190
from rq.exceptions import NoSuchJobError
9291
from rq.job import Job
@@ -146,6 +145,11 @@ class Meta:
146145
abstract = True
147146

148147

148+
class APIToken(AbstractAPIToken):
149+
class Meta:
150+
verbose_name = "API Token"
151+
152+
149153
class HashFieldsMixin(models.Model):
150154
"""
151155
The hash fields are not indexed by default, use the `indexes` in Meta as needed:
@@ -4963,13 +4967,6 @@ def success(self):
49634967
return self.response_status_code in (200, 201, 202)
49644968

49654969

4966-
@receiver(models.signals.post_save, sender=settings.AUTH_USER_MODEL)
4967-
def create_auth_token(sender, instance=None, created=False, **kwargs):
4968-
"""Create an API key token on user creation, using the signal system."""
4969-
if created:
4970-
Token.objects.create(user_id=instance.pk)
4971-
4972-
49734970
class DiscoveredPackageScore(UUIDPKModel, PackageScoreMixin):
49744971
"""Represents a security or quality score for a DiscoveredPackage."""
49754972

scanpipe/templates/account/profile.html

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,46 @@
1313
</ul>
1414
</nav>
1515
</div>
16-
17-
<article class="message is-warning">
18-
<div class="message-body">
19-
<strong>An API key is like a password and should be treated with the same care.</strong>
20-
</div>
21-
</article>
22-
23-
<div class="field">
24-
<label class="label">API Key</label>
25-
<div class="control has-icons-left">
26-
<input class="input" type="text" value="{{ request.user.auth_token.key|default:'Not available' }}" readonly>
27-
<span class="icon is-small is-left">
28-
<i class="fa-solid fa-key"></i>
29-
</span>
16+
<div class="columns">
17+
<div class="column is-7">
18+
<div class="content">
19+
<div class="mb-2">
20+
Your personal API key provides access to the
21+
<a href="{% url 'project-list' %}" target="_blank">REST API</a>
22+
<div class="has-text-weight-semibold">
23+
Treat it like a password and keep it secure.
24+
</div>
25+
</div>
26+
{% if request.user.api_token %}
27+
<div class="notification is-grey mb-4">
28+
Your API key <strong>{{ request.user.api_token.prefix }}...</strong>
29+
was generated on {{ request.user.api_token.created }}<br>
30+
For security reasons, the full key is only shown once at generation time.<br>
31+
If you lose it, you will need to regenerate a new one.
32+
</div>
33+
{% else %}
34+
<div class="notification is-warning is-light mb-4">
35+
<strong>No API key created.</strong><br>
36+
Generate one using the button below to access the REST API.
37+
</div>
38+
{% endif %}
39+
</div>
40+
<div class="buttons">
41+
<button type="button" class="button is-success is-outlined modal-button" data-target="modal-generate-api-key" aria-haspopup="true">
42+
Generate API key
43+
</button>
44+
{% if request.user.api_token %}
45+
<button type="button" class="button is-danger is-outlined modal-button" data-target="modal-revoke-api-key" aria-haspopup="true">
46+
Revoke API key
47+
</button>
48+
{% endif %}
49+
</div>
3050
</div>
3151
</div>
32-
3352
</section>
53+
{% include 'scanpipe/modals/profile_generate_api_key_modal.html' %}
54+
{% if request.user.api_token %}
55+
{% include 'scanpipe/modals/profile_revoke_api_key_modal.html' %}
56+
{% endif %}
3457
</div>
3558
{% endblock %}

0 commit comments

Comments
 (0)