From 1b9dbe33a2644af8e38cb47a0c8669b8bf377049 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Tue, 26 May 2026 12:05:31 -0500 Subject: [PATCH 1/9] API token revocation --- AGENTS.md | 0 Dockerfile.django-alpine | 0 Dockerfile.django-debian | 0 Dockerfile.integration-tests-debian | 0 Dockerfile.nginx-alpine | 0 LICENSE.md | 0 NOTICE | 0 README.md | 0 SECURITY.md | 0 app.json | 0 ct.yaml | 0 docker-compose.override.dev.yml | 0 docker-compose.override.https.yml | 0 docker-compose.override.integration_tests.yml | 0 docker-compose.override.unit_tests.yml | 0 docker-compose.override.unit_tests_cicd.yml | 0 docker-compose.yml | 0 dojo/__init__.py | 0 dojo/admin.py | 0 dojo/api_v2/serializers.py | 23 +++++++++++++ dojo/api_v2/views.py | 32 ++++++++++++++++++- dojo/apps.py | 0 dojo/celery.py | 0 dojo/celery_dispatch.py | 0 dojo/checks.py | 0 dojo/context_processors.py | 0 .../0269_usercontactinfo_token_expiry.py | 16 ++++++++++ dojo/decorators.py | 0 dojo/filters.py | 0 dojo/forms.py | 0 dojo/labels.py | 0 dojo/middleware.py | 0 dojo/models.py | 1 + dojo/pghistory_models.py | 0 dojo/pghistory_utils.py | 0 dojo/product_announcements.py | 0 dojo/query_utils.py | 0 dojo/settings/settings.dist.py | 5 ++- dojo/signals.py | 0 dojo/tasks.py | 0 dojo/template_loaders.py | 0 dojo/templates/dojo/api_v2_key.html | 5 +++ dojo/urls.py | 2 ++ dojo/user/authentication.py | 18 +++++++++-- dojo/user/views.py | 2 ++ dojo/utils.py | 0 dojo/utils_cascade_delete.py | 0 dojo/utils_ssrf.py | 0 dojo/validators.py | 0 dojo/views.py | 0 dojo/widgets.py | 0 dojo/wsgi.py | 0 requirements-dev.txt | 0 requirements-lint.txt | 0 requirements.txt | 0 ruff.toml | 0 wsgi_params | 0 57 files changed, 100 insertions(+), 4 deletions(-) mode change 100644 => 100755 AGENTS.md mode change 100644 => 100755 Dockerfile.django-alpine mode change 100644 => 100755 Dockerfile.django-debian mode change 100644 => 100755 Dockerfile.integration-tests-debian mode change 100644 => 100755 Dockerfile.nginx-alpine mode change 100644 => 100755 LICENSE.md mode change 100644 => 100755 NOTICE mode change 100644 => 100755 README.md mode change 100644 => 100755 SECURITY.md mode change 100644 => 100755 app.json mode change 100644 => 100755 ct.yaml mode change 100644 => 100755 docker-compose.override.dev.yml mode change 100644 => 100755 docker-compose.override.https.yml mode change 100644 => 100755 docker-compose.override.integration_tests.yml mode change 100644 => 100755 docker-compose.override.unit_tests.yml mode change 100644 => 100755 docker-compose.override.unit_tests_cicd.yml mode change 100644 => 100755 docker-compose.yml mode change 100644 => 100755 dojo/__init__.py mode change 100644 => 100755 dojo/admin.py mode change 100644 => 100755 dojo/apps.py mode change 100644 => 100755 dojo/celery.py mode change 100644 => 100755 dojo/celery_dispatch.py mode change 100644 => 100755 dojo/checks.py mode change 100644 => 100755 dojo/context_processors.py create mode 100644 dojo/db_migrations/0269_usercontactinfo_token_expiry.py mode change 100644 => 100755 dojo/decorators.py mode change 100644 => 100755 dojo/filters.py mode change 100644 => 100755 dojo/forms.py mode change 100644 => 100755 dojo/labels.py mode change 100644 => 100755 dojo/middleware.py mode change 100644 => 100755 dojo/models.py mode change 100644 => 100755 dojo/pghistory_models.py mode change 100644 => 100755 dojo/pghistory_utils.py mode change 100644 => 100755 dojo/product_announcements.py mode change 100644 => 100755 dojo/query_utils.py mode change 100644 => 100755 dojo/signals.py mode change 100644 => 100755 dojo/tasks.py mode change 100644 => 100755 dojo/template_loaders.py mode change 100644 => 100755 dojo/urls.py mode change 100644 => 100755 dojo/utils.py mode change 100644 => 100755 dojo/utils_cascade_delete.py mode change 100644 => 100755 dojo/utils_ssrf.py mode change 100644 => 100755 dojo/validators.py mode change 100644 => 100755 dojo/views.py mode change 100644 => 100755 dojo/widgets.py mode change 100644 => 100755 dojo/wsgi.py mode change 100644 => 100755 requirements-dev.txt mode change 100644 => 100755 requirements-lint.txt mode change 100644 => 100755 requirements.txt mode change 100644 => 100755 ruff.toml mode change 100644 => 100755 wsgi_params diff --git a/AGENTS.md b/AGENTS.md old mode 100644 new mode 100755 diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine old mode 100644 new mode 100755 diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian old mode 100644 new mode 100755 diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian old mode 100644 new mode 100755 diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine old mode 100644 new mode 100755 diff --git a/LICENSE.md b/LICENSE.md old mode 100644 new mode 100755 diff --git a/NOTICE b/NOTICE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/SECURITY.md b/SECURITY.md old mode 100644 new mode 100755 diff --git a/app.json b/app.json old mode 100644 new mode 100755 diff --git a/ct.yaml b/ct.yaml old mode 100644 new mode 100755 diff --git a/docker-compose.override.dev.yml b/docker-compose.override.dev.yml old mode 100644 new mode 100755 diff --git a/docker-compose.override.https.yml b/docker-compose.override.https.yml old mode 100644 new mode 100755 diff --git a/docker-compose.override.integration_tests.yml b/docker-compose.override.integration_tests.yml old mode 100644 new mode 100755 diff --git a/docker-compose.override.unit_tests.yml b/docker-compose.override.unit_tests.yml old mode 100644 new mode 100755 diff --git a/docker-compose.override.unit_tests_cicd.yml b/docker-compose.override.unit_tests_cicd.yml old mode 100644 new mode 100755 diff --git a/docker-compose.yml b/docker-compose.yml old mode 100644 new mode 100755 diff --git a/dojo/__init__.py b/dojo/__init__.py old mode 100644 new mode 100755 diff --git a/dojo/admin.py b/dojo/admin.py old mode 100644 new mode 100755 diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index dc15ac6c2dc..df6d77c9d45 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from rest_framework.authtoken.models import Token from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError as RestFrameworkValidationError from rest_framework.fields import DictField @@ -481,6 +482,7 @@ class UserSerializer(serializers.ModelSerializer): last_login = serializers.DateTimeField(read_only=True, allow_null=True) email = serializers.EmailField(required=True) token_last_reset = serializers.SerializerMethodField() + token_expiry = serializers.SerializerMethodField() password_last_reset = serializers.SerializerMethodField() password = serializers.CharField( write_only=True, @@ -512,6 +514,7 @@ class Meta: "is_staff", "is_superuser", "token_last_reset", + "token_expiry", "password_last_reset", "password", "configuration_permissions", @@ -522,6 +525,11 @@ def get_token_last_reset(self, instance): uci = getattr(instance, "usercontactinfo", None) return getattr(uci, "token_last_reset", None) + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_token_expiry(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "token_expiry", None) + @extend_schema_field(serializers.DateTimeField(allow_null=True)) def get_password_last_reset(self, instance): uci = getattr(instance, "usercontactinfo", None) @@ -637,6 +645,21 @@ def validate(self, data): return super().validate(data) +class ApiTokenSerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(source="user.id", read_only=True) + username = serializers.CharField(source="user.username", read_only=True) + expiry = serializers.SerializerMethodField() + + class Meta: + model = Token + fields = ["user_id", "username", "created", "expiry"] + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_expiry(self, obj): + uci = getattr(obj.user, "usercontactinfo", None) + return getattr(uci, "token_expiry", None) + + class UserStubSerializer(serializers.ModelSerializer): class Meta: model = Dojo_User diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3f8bb0cf169..0f75d732c06 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -30,6 +30,7 @@ ) from drf_spectacular.views import SpectacularAPIView from rest_framework import mixins, status, viewsets +from rest_framework.authtoken.models import Token from rest_framework.decorators import action from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser @@ -46,7 +47,7 @@ ) from dojo.api_v2.prefetch.prefetcher import _Prefetcher from dojo.authorization import api_permissions as permissions -from dojo.authorization.authorization import user_has_permission_or_403 +from dojo.authorization.authorization import user_has_permission_or_403, user_is_superuser_or_global_owner from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.queries import ( get_authorized_endpoint_status, @@ -2288,6 +2289,35 @@ def get_queryset(self): return UserContactInfo.objects.all().order_by("id") +# Authorization: superuser/global-owner (all tokens) or self (own token only) +class ApiTokenViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + serializer_class = serializers.ApiTokenSerializer + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["user_id"] + lookup_field = "user_id" + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + qs = Token.objects.select_related("user", "user__usercontactinfo").order_by("user_id") + if not user_is_superuser_or_global_owner(self.request.user): + qs = qs.filter(user=self.request.user) + return qs + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + uci = getattr(instance.user, "usercontactinfo", None) + if uci: + uci.token_expiry = None + uci.save(update_fields=["token_expiry"]) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + + # Authorization: authenticated users class UserProfileView(GenericAPIView): permission_classes = (IsAuthenticated,) diff --git a/dojo/apps.py b/dojo/apps.py old mode 100644 new mode 100755 diff --git a/dojo/celery.py b/dojo/celery.py old mode 100644 new mode 100755 diff --git a/dojo/celery_dispatch.py b/dojo/celery_dispatch.py old mode 100644 new mode 100755 diff --git a/dojo/checks.py b/dojo/checks.py old mode 100644 new mode 100755 diff --git a/dojo/context_processors.py b/dojo/context_processors.py old mode 100644 new mode 100755 diff --git a/dojo/db_migrations/0269_usercontactinfo_token_expiry.py b/dojo/db_migrations/0269_usercontactinfo_token_expiry.py new file mode 100644 index 00000000000..099ef672c13 --- /dev/null +++ b/dojo/db_migrations/0269_usercontactinfo_token_expiry.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0268_release_authorization_to_pro'), + ] + + operations = [ + migrations.AddField( + model_name='usercontactinfo', + name='token_expiry', + field=models.DateTimeField(blank=True, help_text="Optional expiry datetime for this user's API token. When set, requests using an expired token are rejected.", null=True), + ), + ] diff --git a/dojo/decorators.py b/dojo/decorators.py old mode 100644 new mode 100755 diff --git a/dojo/filters.py b/dojo/filters.py old mode 100644 new mode 100755 diff --git a/dojo/forms.py b/dojo/forms.py old mode 100644 new mode 100755 diff --git a/dojo/labels.py b/dojo/labels.py old mode 100644 new mode 100755 diff --git a/dojo/middleware.py b/dojo/middleware.py old mode 100644 new mode 100755 diff --git a/dojo/models.py b/dojo/models.py old mode 100644 new mode 100755 index a41f5640889..c62438468de --- a/dojo/models.py +++ b/dojo/models.py @@ -259,6 +259,7 @@ class UserContactInfo(models.Model): force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) + token_expiry = models.DateTimeField(null=True, blank=True, help_text=_("Optional expiry datetime for this user's API token. When set, requests using an expired token are rejected.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) diff --git a/dojo/pghistory_models.py b/dojo/pghistory_models.py old mode 100644 new mode 100755 diff --git a/dojo/pghistory_utils.py b/dojo/pghistory_utils.py old mode 100644 new mode 100755 diff --git a/dojo/product_announcements.py b/dojo/product_announcements.py old mode 100644 new mode 100755 diff --git a/dojo/query_utils.py b/dojo/query_utils.py old mode 100644 new mode 100755 diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 70c58bddbee..652774c2fad 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -237,6 +237,8 @@ DD_API_TOKENS_ENABLED=(bool, True), # Enable endpoint which allow user to get API token when user+pass is provided DD_API_TOKEN_AUTH_ENDPOINT_ENABLED=(bool, True), + # Default token lifetime in days. 0 = no expiry (tokens last forever). + DD_API_TOKEN_DEFAULT_EXPIRY_DAYS=(int, 0), # You can set extra Jira headers by suppling a dictionary in header: value format (pass as env var like "headr_name=value,another_header=anohter_value") DD_ADDITIONAL_HEADERS=(dict, {}), # Set fields used by the hashcode generator for deduplication, via en env variable that contains a JSON string @@ -639,6 +641,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param API_TOKENS_ENABLED = env("DD_API_TOKENS_ENABLED") API_TOKEN_AUTH_ENDPOINT_ENABLED = env("DD_API_TOKEN_AUTH_ENDPOINT_ENABLED") +API_TOKEN_DEFAULT_EXPIRY_DAYS = env("DD_API_TOKEN_DEFAULT_EXPIRY_DAYS") REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", @@ -658,7 +661,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param } if API_TOKENS_ENABLED: - REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] += ("rest_framework.authentication.TokenAuthentication",) + REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] += ("dojo.user.authentication.ExpiringTokenAuthentication",) SPECTACULAR_SETTINGS = { "TITLE": "DefectDojo API v2", diff --git a/dojo/signals.py b/dojo/signals.py old mode 100644 new mode 100755 diff --git a/dojo/tasks.py b/dojo/tasks.py old mode 100644 new mode 100755 diff --git a/dojo/template_loaders.py b/dojo/template_loaders.py old mode 100644 new mode 100755 diff --git a/dojo/templates/dojo/api_v2_key.html b/dojo/templates/dojo/api_v2_key.html index 6b4d56e9338..eb2b8d83a11 100644 --- a/dojo/templates/dojo/api_v2_key.html +++ b/dojo/templates/dojo/api_v2_key.html @@ -8,6 +8,11 @@

{{ name }}


{% trans "Your current API key is" %} {{ key.key }}

{% trans "Your current API Authorization Header value is" %} Token {{ key.key }}

+ {% if token_expiry %} +

{% trans "Your API key expires on" %} {{ token_expiry }}

+ {% else %} +

{% trans "Your API key does not expire." %}

+ {% endif %}

{% trans "Has your key been exposed? Are you ready for a new one?" %}

{% csrf_token %} diff --git a/dojo/urls.py b/dojo/urls.py old mode 100644 new mode 100755 index 9b9a8d6a399..49d4f211ba7 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -13,6 +13,7 @@ from dojo.announcement.urls import urlpatterns as announcement_urls from dojo.api_v2.views import ( AnnouncementViewSet, + ApiTokenViewSet, AppAnalysisViewSet, BurpRawRequestResponseViewSet, CeleryViewSet, @@ -155,6 +156,7 @@ v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") +v2_api.register(r"api-tokens", ApiTokenViewSet, basename="api-token") v2_api.register(r"users", UsersViewSet, basename="user") v2_api.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") # Add the location routes diff --git a/dojo/user/authentication.py b/dojo/user/authentication.py index 11f8fce104c..23949f5dc00 100644 --- a/dojo/user/authentication.py +++ b/dojo/user/authentication.py @@ -1,14 +1,26 @@ +from datetime import timedelta + from django.conf import settings from django.urls import reverse from django.utils import timezone +from rest_framework.authtoken.authentication import TokenAuthentication from rest_framework.authtoken.models import Token -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied, ValidationError from dojo.authorization.authorization import user_is_superuser_or_global_owner from dojo.models import Dojo_User, UserContactInfo from dojo.notifications.helper import create_notification +class ExpiringTokenAuthentication(TokenAuthentication): + def authenticate_credentials(self, key): + user, token = super().authenticate_credentials(key) + uci = getattr(user, "usercontactinfo", None) + if uci and uci.token_expiry and uci.token_expiry < timezone.now(): + raise AuthenticationFailed("Token has expired.") + return user, token + + def reset_token_for_user(*, acting_user: Dojo_User, target_user: Dojo_User, allow_self_reset: bool = False) -> None: if not settings.API_TOKENS_ENABLED: msg = "API tokens are disabled." @@ -31,9 +43,11 @@ def reset_token_for_user(*, acting_user: Dojo_User, target_user: Dojo_User, allo Token.objects.filter(user=target_user).delete() Token.objects.create(user=target_user) + expiry_days = getattr(settings, "API_TOKEN_DEFAULT_EXPIRY_DAYS", 0) uci, _ = UserContactInfo.objects.get_or_create(user=target_user) uci.token_last_reset = timezone.now() - uci.save(update_fields=["token_last_reset"]) + uci.token_expiry = timezone.now() + timedelta(days=expiry_days) if expiry_days else None + uci.save(update_fields=["token_last_reset", "token_expiry"]) # Send notification to the target user if acting_user == target_user: diff --git a/dojo/user/views.py b/dojo/user/views.py index ce3d8449a39..dbebecea7e6 100644 --- a/dojo/user/views.py +++ b/dojo/user/views.py @@ -103,6 +103,7 @@ def api_v2_key(request): api_key = Token.objects.get(user=request.user) except Token.DoesNotExist: api_key = Token.objects.create(user=request.user) + uci = getattr(request.user, "usercontactinfo", None) add_breadcrumb(title=_("API Key"), top_level=True, request=request) return render(request, "dojo/api_v2_key.html", @@ -110,6 +111,7 @@ def api_v2_key(request): "metric": False, "user": request.user, "key": api_key, + "token_expiry": getattr(uci, "token_expiry", None), "form": form, }) diff --git a/dojo/utils.py b/dojo/utils.py old mode 100644 new mode 100755 diff --git a/dojo/utils_cascade_delete.py b/dojo/utils_cascade_delete.py old mode 100644 new mode 100755 diff --git a/dojo/utils_ssrf.py b/dojo/utils_ssrf.py old mode 100644 new mode 100755 diff --git a/dojo/validators.py b/dojo/validators.py old mode 100644 new mode 100755 diff --git a/dojo/views.py b/dojo/views.py old mode 100644 new mode 100755 diff --git a/dojo/widgets.py b/dojo/widgets.py old mode 100644 new mode 100755 diff --git a/dojo/wsgi.py b/dojo/wsgi.py old mode 100644 new mode 100755 diff --git a/requirements-dev.txt b/requirements-dev.txt old mode 100644 new mode 100755 diff --git a/requirements-lint.txt b/requirements-lint.txt old mode 100644 new mode 100755 diff --git a/requirements.txt b/requirements.txt old mode 100644 new mode 100755 diff --git a/ruff.toml b/ruff.toml old mode 100644 new mode 100755 diff --git a/wsgi_params b/wsgi_params old mode 100644 new mode 100755 From 93e159d2f788e2720b773a8785586ad4b5e4779d Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Tue, 26 May 2026 19:47:55 -0500 Subject: [PATCH 2/9] Correct TokenAuthentication import and add expiry to classic template --- AGENTS.md | 0 Dockerfile.django-alpine | 0 Dockerfile.django-debian | 0 Dockerfile.integration-tests-debian | 0 Dockerfile.nginx-alpine | 0 LICENSE.md | 0 NOTICE | 0 README.md | 0 SECURITY.md | 0 app.json | 0 ct.yaml | 0 docker-compose.override.dev.yml | 0 docker-compose.override.https.yml | 0 docker-compose.override.integration_tests.yml | 0 docker-compose.override.unit_tests.yml | 0 docker-compose.override.unit_tests_cicd.yml | 0 docker-compose.yml | 0 dojo/__init__.py | 0 dojo/admin.py | 0 dojo/apps.py | 0 dojo/celery.py | 0 dojo/celery_dispatch.py | 0 dojo/checks.py | 0 dojo/context_processors.py | 0 dojo/decorators.py | 0 dojo/filters.py | 0 dojo/forms.py | 0 dojo/labels.py | 0 dojo/middleware.py | 0 dojo/models.py | 1 - dojo/pghistory_models.py | 0 dojo/pghistory_utils.py | 0 dojo/product_announcements.py | 0 dojo/query_utils.py | 0 dojo/signals.py | 0 dojo/tasks.py | 0 dojo/template_loaders.py | 0 dojo/templates_classic/dojo/api_v2_key.html | 5 +++++ dojo/urls.py | 2 -- dojo/user/authentication.py | 2 +- dojo/utils.py | 0 dojo/utils_cascade_delete.py | 0 dojo/utils_ssrf.py | 0 dojo/validators.py | 0 dojo/views.py | 0 dojo/widgets.py | 0 dojo/wsgi.py | 0 requirements-dev.txt | 0 requirements-lint.txt | 0 requirements.txt | 0 ruff.toml | 0 wsgi_params | 0 52 files changed, 6 insertions(+), 4 deletions(-) mode change 100755 => 100644 AGENTS.md mode change 100755 => 100644 Dockerfile.django-alpine mode change 100755 => 100644 Dockerfile.django-debian mode change 100755 => 100644 Dockerfile.integration-tests-debian mode change 100755 => 100644 Dockerfile.nginx-alpine mode change 100755 => 100644 LICENSE.md mode change 100755 => 100644 NOTICE mode change 100755 => 100644 README.md mode change 100755 => 100644 SECURITY.md mode change 100755 => 100644 app.json mode change 100755 => 100644 ct.yaml mode change 100755 => 100644 docker-compose.override.dev.yml mode change 100755 => 100644 docker-compose.override.https.yml mode change 100755 => 100644 docker-compose.override.integration_tests.yml mode change 100755 => 100644 docker-compose.override.unit_tests.yml mode change 100755 => 100644 docker-compose.override.unit_tests_cicd.yml mode change 100755 => 100644 docker-compose.yml mode change 100755 => 100644 dojo/__init__.py mode change 100755 => 100644 dojo/admin.py mode change 100755 => 100644 dojo/apps.py mode change 100755 => 100644 dojo/celery.py mode change 100755 => 100644 dojo/celery_dispatch.py mode change 100755 => 100644 dojo/checks.py mode change 100755 => 100644 dojo/context_processors.py mode change 100755 => 100644 dojo/decorators.py mode change 100755 => 100644 dojo/filters.py mode change 100755 => 100644 dojo/forms.py mode change 100755 => 100644 dojo/labels.py mode change 100755 => 100644 dojo/middleware.py mode change 100755 => 100644 dojo/models.py mode change 100755 => 100644 dojo/pghistory_models.py mode change 100755 => 100644 dojo/pghistory_utils.py mode change 100755 => 100644 dojo/product_announcements.py mode change 100755 => 100644 dojo/query_utils.py mode change 100755 => 100644 dojo/signals.py mode change 100755 => 100644 dojo/tasks.py mode change 100755 => 100644 dojo/template_loaders.py mode change 100755 => 100644 dojo/urls.py mode change 100755 => 100644 dojo/utils.py mode change 100755 => 100644 dojo/utils_cascade_delete.py mode change 100755 => 100644 dojo/utils_ssrf.py mode change 100755 => 100644 dojo/validators.py mode change 100755 => 100644 dojo/views.py mode change 100755 => 100644 dojo/widgets.py mode change 100755 => 100644 dojo/wsgi.py mode change 100755 => 100644 requirements-dev.txt mode change 100755 => 100644 requirements-lint.txt mode change 100755 => 100644 requirements.txt mode change 100755 => 100644 ruff.toml mode change 100755 => 100644 wsgi_params diff --git a/AGENTS.md b/AGENTS.md old mode 100755 new mode 100644 diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine old mode 100755 new mode 100644 diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian old mode 100755 new mode 100644 diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian old mode 100755 new mode 100644 diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine old mode 100755 new mode 100644 diff --git a/LICENSE.md b/LICENSE.md old mode 100755 new mode 100644 diff --git a/NOTICE b/NOTICE old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/SECURITY.md b/SECURITY.md old mode 100755 new mode 100644 diff --git a/app.json b/app.json old mode 100755 new mode 100644 diff --git a/ct.yaml b/ct.yaml old mode 100755 new mode 100644 diff --git a/docker-compose.override.dev.yml b/docker-compose.override.dev.yml old mode 100755 new mode 100644 diff --git a/docker-compose.override.https.yml b/docker-compose.override.https.yml old mode 100755 new mode 100644 diff --git a/docker-compose.override.integration_tests.yml b/docker-compose.override.integration_tests.yml old mode 100755 new mode 100644 diff --git a/docker-compose.override.unit_tests.yml b/docker-compose.override.unit_tests.yml old mode 100755 new mode 100644 diff --git a/docker-compose.override.unit_tests_cicd.yml b/docker-compose.override.unit_tests_cicd.yml old mode 100755 new mode 100644 diff --git a/docker-compose.yml b/docker-compose.yml old mode 100755 new mode 100644 diff --git a/dojo/__init__.py b/dojo/__init__.py old mode 100755 new mode 100644 diff --git a/dojo/admin.py b/dojo/admin.py old mode 100755 new mode 100644 diff --git a/dojo/apps.py b/dojo/apps.py old mode 100755 new mode 100644 diff --git a/dojo/celery.py b/dojo/celery.py old mode 100755 new mode 100644 diff --git a/dojo/celery_dispatch.py b/dojo/celery_dispatch.py old mode 100755 new mode 100644 diff --git a/dojo/checks.py b/dojo/checks.py old mode 100755 new mode 100644 diff --git a/dojo/context_processors.py b/dojo/context_processors.py old mode 100755 new mode 100644 diff --git a/dojo/decorators.py b/dojo/decorators.py old mode 100755 new mode 100644 diff --git a/dojo/filters.py b/dojo/filters.py old mode 100755 new mode 100644 diff --git a/dojo/forms.py b/dojo/forms.py old mode 100755 new mode 100644 diff --git a/dojo/labels.py b/dojo/labels.py old mode 100755 new mode 100644 diff --git a/dojo/middleware.py b/dojo/middleware.py old mode 100755 new mode 100644 diff --git a/dojo/models.py b/dojo/models.py old mode 100755 new mode 100644 index c62438468de..a41f5640889 --- a/dojo/models.py +++ b/dojo/models.py @@ -259,7 +259,6 @@ class UserContactInfo(models.Model): force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) - token_expiry = models.DateTimeField(null=True, blank=True, help_text=_("Optional expiry datetime for this user's API token. When set, requests using an expired token are rejected.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) diff --git a/dojo/pghistory_models.py b/dojo/pghistory_models.py old mode 100755 new mode 100644 diff --git a/dojo/pghistory_utils.py b/dojo/pghistory_utils.py old mode 100755 new mode 100644 diff --git a/dojo/product_announcements.py b/dojo/product_announcements.py old mode 100755 new mode 100644 diff --git a/dojo/query_utils.py b/dojo/query_utils.py old mode 100755 new mode 100644 diff --git a/dojo/signals.py b/dojo/signals.py old mode 100755 new mode 100644 diff --git a/dojo/tasks.py b/dojo/tasks.py old mode 100755 new mode 100644 diff --git a/dojo/template_loaders.py b/dojo/template_loaders.py old mode 100755 new mode 100644 diff --git a/dojo/templates_classic/dojo/api_v2_key.html b/dojo/templates_classic/dojo/api_v2_key.html index 6b4d56e9338..eb2b8d83a11 100644 --- a/dojo/templates_classic/dojo/api_v2_key.html +++ b/dojo/templates_classic/dojo/api_v2_key.html @@ -8,6 +8,11 @@

{{ name }}


{% trans "Your current API key is" %} {{ key.key }}

{% trans "Your current API Authorization Header value is" %} Token {{ key.key }}

+ {% if token_expiry %} +

{% trans "Your API key expires on" %} {{ token_expiry }}

+ {% else %} +

{% trans "Your API key does not expire." %}

+ {% endif %}

{% trans "Has your key been exposed? Are you ready for a new one?" %}

{% csrf_token %} diff --git a/dojo/urls.py b/dojo/urls.py old mode 100755 new mode 100644 index 49d4f211ba7..9b9a8d6a399 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -13,7 +13,6 @@ from dojo.announcement.urls import urlpatterns as announcement_urls from dojo.api_v2.views import ( AnnouncementViewSet, - ApiTokenViewSet, AppAnalysisViewSet, BurpRawRequestResponseViewSet, CeleryViewSet, @@ -156,7 +155,6 @@ v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") -v2_api.register(r"api-tokens", ApiTokenViewSet, basename="api-token") v2_api.register(r"users", UsersViewSet, basename="user") v2_api.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") # Add the location routes diff --git a/dojo/user/authentication.py b/dojo/user/authentication.py index 23949f5dc00..13e17ce74a1 100644 --- a/dojo/user/authentication.py +++ b/dojo/user/authentication.py @@ -3,7 +3,7 @@ from django.conf import settings from django.urls import reverse from django.utils import timezone -from rest_framework.authtoken.authentication import TokenAuthentication +from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token from rest_framework.exceptions import AuthenticationFailed, PermissionDenied, ValidationError diff --git a/dojo/utils.py b/dojo/utils.py old mode 100755 new mode 100644 diff --git a/dojo/utils_cascade_delete.py b/dojo/utils_cascade_delete.py old mode 100755 new mode 100644 diff --git a/dojo/utils_ssrf.py b/dojo/utils_ssrf.py old mode 100755 new mode 100644 diff --git a/dojo/validators.py b/dojo/validators.py old mode 100755 new mode 100644 diff --git a/dojo/views.py b/dojo/views.py old mode 100755 new mode 100644 diff --git a/dojo/widgets.py b/dojo/widgets.py old mode 100755 new mode 100644 diff --git a/dojo/wsgi.py b/dojo/wsgi.py old mode 100755 new mode 100644 diff --git a/requirements-dev.txt b/requirements-dev.txt old mode 100755 new mode 100644 diff --git a/requirements-lint.txt b/requirements-lint.txt old mode 100755 new mode 100644 diff --git a/requirements.txt b/requirements.txt old mode 100755 new mode 100644 diff --git a/ruff.toml b/ruff.toml old mode 100755 new mode 100644 diff --git a/wsgi_params b/wsgi_params old mode 100755 new mode 100644 From 9d5e7550d2e6028c21e6976ac158373f00fc25e7 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Wed, 27 May 2026 23:50:42 -0500 Subject: [PATCH 3/9] Ruff linter compliance for token expiry feature --- dojo/user/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dojo/user/authentication.py b/dojo/user/authentication.py index 13e17ce74a1..7b89e2c4cd8 100644 --- a/dojo/user/authentication.py +++ b/dojo/user/authentication.py @@ -17,7 +17,8 @@ def authenticate_credentials(self, key): user, token = super().authenticate_credentials(key) uci = getattr(user, "usercontactinfo", None) if uci and uci.token_expiry and uci.token_expiry < timezone.now(): - raise AuthenticationFailed("Token has expired.") + msg = "API Token has expired." + raise AuthenticationFailed(msg) return user, token From 42d3922aa03fb02ca9de174142fc085fd6d1f9e7 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Fri, 29 May 2026 17:10:53 -0500 Subject: [PATCH 4/9] Enhance API token management documentation and improve token expiry error message --- docs/content/automation/api/api-v2-docs.md | 68 ++++++++++++++++++++++ dojo/api_v2/views.py | 13 +++++ dojo/user/authentication.py | 2 +- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/content/automation/api/api-v2-docs.md b/docs/content/automation/api/api-v2-docs.md index 82fb8730765..5065ce2eef3 100644 --- a/docs/content/automation/api/api-v2-docs.md +++ b/docs/content/automation/api/api-v2-docs.md @@ -47,6 +47,74 @@ If you use [an alternative authentication method](/admin/sso/) for users, you ma Using of DefectDojo API tokens can be disabled by specifying the environment variable `DD_API_TOKENS_ENABLED` to `False`. Or only `api/v2/api-token-auth/` endpoint can be disabled by setting `DD_API_TOKEN_AUTH_ENDPOINT_ENABLED` to `False`. +### Token management + +DefectDojo provides API endpoints to list, inspect, and revoke API tokens programmatically. + +#### Listing tokens + +Superusers and Global Owners can list all active tokens across all users. Regular users can only see their own token. + +``` +GET /api/v2/api-tokens/ +Authorization: Token +``` + +Response fields: `user_id`, `username`, `created`, `expiry` (null if the token does not expire). + +To filter to a specific user: + +``` +GET /api/v2/api-tokens/?user_id=5 +``` + +#### Retrieving a token + +``` +GET /api/v2/api-tokens/{user_id}/ +``` + +Superusers can retrieve any user's token. Regular users are restricted to their own user ID. Any attempt by a regular user to retrieve another user's ID is met with a 404. + +#### Revoking a token + +``` +DELETE /api/v2/api-tokens/{user_id}/ +Authorization: Token +``` + +Returns 204 on success. The token is immediately invalidated. The user will need to generate a new token via the UI (`/api_v2_key/`) or via `POST /api/v2/users/{id}/reset_api_token/` before they can authenticate again. + +Like retrieval, superuser can revoke any user's token. Regular users can only revoke their own. + +#### Token expiry + +An optional expiry datetime can be set per user via the `user_contact_infos` endpoint (superuser only): + +``` +PATCH /api/v2/user_contact_infos/{id}/ +Authorization: Token +Content-Type: application/json + +{"token_expiry": "2026-12-31T23:59:59Z"} +``` + +Once set, any request using that user's token after the expiry datetime will receive a `401 Unauthorized` response with `{"detail": "Token has expired."}`. The user must generate a new token to regain access. + +To remove an expiry and make a token permanent again, set `token_expiry` to `null`. + +#### Default token lifetime + +To enforce a maximum token lifetime across all users, set the environment variable: + +``` +DD_API_TOKEN_DEFAULT_EXPIRY_DAYS=90 +``` + +When set to a value greater than `0`, every token rotation (via `POST /api/v2/users/{id}/reset_api_token/` or the UI) will automatically set `token_expiry` to the current time plus the configured number of days. The default is `0`, meaning tokens do not expire unless explicitly set. + +Note: resetting a token always snaps the expiry back to the instance default. Per-user expiry overrides set via `user_contact_infos` do not persist across token resets. + ## Sample Code Here are some simple python examples and their results produced against diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 0f75d732c06..50adeb384d5 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -2296,6 +2296,19 @@ class ApiTokenViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet, ): + + """ + Manage API tokens. Tokens are looked up by the owner's user ID. + + Superusers and Global Owners can list, retrieve, and revoke tokens for any user. + Regular users can only list, retrieve, and revoke their own token. + + Revoking a token (DELETE) immediately invalidates it. The user must generate + a new token via the UI or POST /api/v2/users/{id}/reset_api_token/ to regain access. + + Token expiry is managed via the user_contact_infos endpoint. + """ + serializer_class = serializers.ApiTokenSerializer filter_backends = (DjangoFilterBackend,) filterset_fields = ["user_id"] diff --git a/dojo/user/authentication.py b/dojo/user/authentication.py index 7b89e2c4cd8..c69963fdb20 100644 --- a/dojo/user/authentication.py +++ b/dojo/user/authentication.py @@ -17,7 +17,7 @@ def authenticate_credentials(self, key): user, token = super().authenticate_credentials(key) uci = getattr(user, "usercontactinfo", None) if uci and uci.token_expiry and uci.token_expiry < timezone.now(): - msg = "API Token has expired." + msg = "API token has expired." raise AuthenticationFailed(msg) return user, token From 0033767942398b83683999892631a32c752ad052 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Fri, 29 May 2026 17:33:40 -0500 Subject: [PATCH 5/9] feat(api-tokens): wire model field, URL registration, and unit tests - Add token_expiry DateTimeField to UserContactInfo (was missing from initial commit despite migration referencing it) - Register ApiTokenViewSet at api-tokens/ in urls.py (was missing from initial commit despite ViewSet existing in views.py) - Add unit tests for list, retrieve, revoke, expiry enforcement, and default-expiry-on-reset behaviours --- dojo/models.py | 1 + dojo/urls.py | 2 + unittests/test_apiv2_token.py | 127 ++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 unittests/test_apiv2_token.py diff --git a/dojo/models.py b/dojo/models.py index a41f5640889..c62438468de 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -259,6 +259,7 @@ class UserContactInfo(models.Model): force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) + token_expiry = models.DateTimeField(null=True, blank=True, help_text=_("Optional expiry datetime for this user's API token. When set, requests using an expired token are rejected.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) diff --git a/dojo/urls.py b/dojo/urls.py index 9b9a8d6a399..49d4f211ba7 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -13,6 +13,7 @@ from dojo.announcement.urls import urlpatterns as announcement_urls from dojo.api_v2.views import ( AnnouncementViewSet, + ApiTokenViewSet, AppAnalysisViewSet, BurpRawRequestResponseViewSet, CeleryViewSet, @@ -155,6 +156,7 @@ v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") +v2_api.register(r"api-tokens", ApiTokenViewSet, basename="api-token") v2_api.register(r"users", UsersViewSet, basename="user") v2_api.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") # Add the location routes diff --git a/unittests/test_apiv2_token.py b/unittests/test_apiv2_token.py new file mode 100644 index 00000000000..156078a7ab2 --- /dev/null +++ b/unittests/test_apiv2_token.py @@ -0,0 +1,127 @@ +from datetime import timedelta + +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient, APITestCase + +from dojo.models import User, UserContactInfo +from unittests.dojo_test_case import versioned_fixtures + + +@versioned_fixtures +class ApiTokenTest(APITestCase): + + """Test the ApiToken APIv2 endpoint.""" + + fixtures = ["dojo_testdata.json"] + + def setUp(self): + token = Token.objects.get(user__username="admin") + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + def _create_user(self, username): + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": username, + "email": f"{username}@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + user = User.objects.get(id=r.json()["id"]) + token = Token.objects.get_or_create(user=user)[0] + return user, token, password + + def _client_for(self, token_key): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token_key) + return client + + def test_api_token_list_superuser_sees_all(self): + r = self.client.get(reverse("api-token-list")) + self.assertEqual(r.status_code, 200, r.content[:1000]) + results = r.json()["results"] + self.assertGreaterEqual(len(results), 1) + for item in results: + for field in ["user_id", "username", "created", "expiry"]: + self.assertIn(field, item) + self.assertNotIn("key", item) + + def test_api_token_list_non_superuser_sees_only_own(self): + user, token, _ = self._create_user("api-token-list-user") + client = self._client_for(token.key) + + r = client.get(reverse("api-token-list")) + self.assertEqual(r.status_code, 200, r.content[:1000]) + results = r.json()["results"] + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["user_id"], user.id) + + def test_api_token_retrieve_other_user_non_superuser_returns_404(self): + user1, token1, _ = self._create_user("api-token-retrieve-user1") + user2, _, _ = self._create_user("api-token-retrieve-user2") + client = self._client_for(token1.key) + + r = client.get("{}{}/".format(reverse("api-token-list"), user2.id)) + self.assertEqual(r.status_code, 404, r.content[:1000]) + + def test_api_token_revoke_as_superuser(self): + user, _, _ = self._create_user("api-token-revoke-super") + + r = self.client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + self.assertEqual(r.status_code, 204, r.content[:1000]) + self.assertFalse(Token.objects.filter(user=user).exists()) + + def test_api_token_revoke_own(self): + user, token, _ = self._create_user("api-token-revoke-self") + client = self._client_for(token.key) + + r = client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + self.assertEqual(r.status_code, 204, r.content[:1000]) + self.assertFalse(Token.objects.filter(user=user).exists()) + + def test_api_token_revoke_clears_expiry(self): + user, _, _ = self._create_user("api-token-revoke-expiry") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() + timedelta(days=30) + uci.save(update_fields=["token_expiry"]) + + self.client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + + uci.refresh_from_db() + self.assertIsNone(uci.token_expiry) + + def test_expired_token_rejected(self): + user, token, _ = self._create_user("api-token-expired") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() - timedelta(days=1) + uci.save(update_fields=["token_expiry"]) + + client = self._client_for(token.key) + r = client.get(reverse("user-list")) + self.assertEqual(r.status_code, 401, r.content[:1000]) + self.assertIn("Token has expired.", r.content.decode("utf-8")) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=7) + def test_default_expiry_applied_on_reset(self): + user, _, _ = self._create_user("api-token-expiry-default") + + r = self.client.post("{}{}/reset_api_token/".format(reverse("user-list"), user.id)) + self.assertEqual(r.status_code, 204, r.content[:1000]) + + uci = UserContactInfo.objects.get(user=user) + self.assertIsNotNone(uci.token_expiry) + expected = timezone.now() + timedelta(days=7) + self.assertLess(abs((expected - uci.token_expiry).total_seconds()), 30) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=0) + def test_no_expiry_when_default_is_zero(self): + user, _, _ = self._create_user("api-token-no-expiry") + + r = self.client.post("{}{}/reset_api_token/".format(reverse("user-list"), user.id)) + self.assertEqual(r.status_code, 204, r.content[:1000]) + + uci = UserContactInfo.objects.get(user=user) + self.assertIsNone(uci.token_expiry) From 5dd8347a8c349ab41ab0ce496335b1ea06fe6439 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Fri, 29 May 2026 17:56:36 -0500 Subject: [PATCH 6/9] Ruff compliance and docs correction --- docs/content/automation/api/api-v2-docs.md | 2 +- unittests/test_apiv2_token.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/content/automation/api/api-v2-docs.md b/docs/content/automation/api/api-v2-docs.md index 5065ce2eef3..de6345e00b9 100644 --- a/docs/content/automation/api/api-v2-docs.md +++ b/docs/content/automation/api/api-v2-docs.md @@ -99,7 +99,7 @@ Content-Type: application/json {"token_expiry": "2026-12-31T23:59:59Z"} ``` -Once set, any request using that user's token after the expiry datetime will receive a `401 Unauthorized` response with `{"detail": "Token has expired."}`. The user must generate a new token to regain access. +Once set, any request using that user's token after the expiry datetime will receive a `403 Forbidden` response with `{"detail": "API token has expired."}`. The user must generate a new token to regain access. To remove an expiry and make a token permanent again, set `token_expiry` to `null`. diff --git a/unittests/test_apiv2_token.py b/unittests/test_apiv2_token.py index 156078a7ab2..1651ddc7344 100644 --- a/unittests/test_apiv2_token.py +++ b/unittests/test_apiv2_token.py @@ -60,7 +60,7 @@ def test_api_token_list_non_superuser_sees_only_own(self): self.assertEqual(results[0]["user_id"], user.id) def test_api_token_retrieve_other_user_non_superuser_returns_404(self): - user1, token1, _ = self._create_user("api-token-retrieve-user1") + _user1, token1, _ = self._create_user("api-token-retrieve-user1") user2, _, _ = self._create_user("api-token-retrieve-user2") client = self._client_for(token1.key) @@ -101,8 +101,8 @@ def test_expired_token_rejected(self): client = self._client_for(token.key) r = client.get(reverse("user-list")) - self.assertEqual(r.status_code, 401, r.content[:1000]) - self.assertIn("Token has expired.", r.content.decode("utf-8")) + self.assertEqual(r.status_code, 403, r.content[:1000]) + self.assertIn("API token has expired.", r.content.decode("utf-8")) @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=7) def test_default_expiry_applied_on_reset(self): From 071d035067d147a693a40ea0e81872099b73e36a Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Fri, 29 May 2026 18:54:59 -0500 Subject: [PATCH 7/9] Fix silent pass in API test --- unittests/test_apiv2_token.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/unittests/test_apiv2_token.py b/unittests/test_apiv2_token.py index 1651ddc7344..2988647dff5 100644 --- a/unittests/test_apiv2_token.py +++ b/unittests/test_apiv2_token.py @@ -88,7 +88,8 @@ def test_api_token_revoke_clears_expiry(self): uci.token_expiry = timezone.now() + timedelta(days=30) uci.save(update_fields=["token_expiry"]) - self.client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + r = self.client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + self.assertEqual(r.status_code, 204, r.content[:1000]) uci.refresh_from_db() self.assertIsNone(uci.token_expiry) @@ -104,6 +105,19 @@ def test_expired_token_rejected(self): self.assertEqual(r.status_code, 403, r.content[:1000]) self.assertIn("API token has expired.", r.content.decode("utf-8")) + def test_user_serializer_exposes_token_expiry(self): + user, _, _ = self._create_user("api-token-user-serializer") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + expiry = timezone.now() + timedelta(days=14) + uci.token_expiry = expiry + uci.save(update_fields=["token_expiry"]) + + r = self.client.get("{}{}/".format(reverse("user-list"), user.id)) + self.assertEqual(r.status_code, 200, r.content[:1000]) + body = r.json() + self.assertIn("token_expiry", body) + self.assertIsNotNone(body["token_expiry"]) + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=7) def test_default_expiry_applied_on_reset(self): user, _, _ = self._create_user("api-token-expiry-default") From c293c655a9300711226461b08f1867d1383392d8 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Sun, 31 May 2026 14:18:07 -0500 Subject: [PATCH 8/9] refactor: replace proposed token viewset with simple revoke-by-key endpoint --- dojo/api_v2/serializers.py | 16 --------- dojo/api_v2/views.py | 57 +++++++++++++++---------------- dojo/urls.py | 4 +-- unittests/test_apiv2_token.py | 64 +++++++++++++---------------------- 4 files changed, 52 insertions(+), 89 deletions(-) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index df6d77c9d45..1ed3ccefd4f 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -19,7 +19,6 @@ from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from rest_framework.authtoken.models import Token from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError as RestFrameworkValidationError from rest_framework.fields import DictField @@ -645,21 +644,6 @@ def validate(self, data): return super().validate(data) -class ApiTokenSerializer(serializers.ModelSerializer): - user_id = serializers.IntegerField(source="user.id", read_only=True) - username = serializers.CharField(source="user.username", read_only=True) - expiry = serializers.SerializerMethodField() - - class Meta: - model = Token - fields = ["user_id", "username", "created", "expiry"] - - @extend_schema_field(serializers.DateTimeField(allow_null=True)) - def get_expiry(self, obj): - uci = getattr(obj.user, "usercontactinfo", None) - return getattr(uci, "token_expiry", None) - - class UserStubSerializer(serializers.ModelSerializer): class Meta: model = Dojo_User diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 50adeb384d5..c57c3f8763a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -32,6 +32,7 @@ from rest_framework import mixins, status, viewsets from rest_framework.authtoken.models import Token from rest_framework.decorators import action +from rest_framework.exceptions import NotFound from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated @@ -47,7 +48,7 @@ ) from dojo.api_v2.prefetch.prefetcher import _Prefetcher from dojo.authorization import api_permissions as permissions -from dojo.authorization.authorization import user_has_permission_or_403, user_is_superuser_or_global_owner +from dojo.authorization.authorization import user_has_permission_or_403 from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.queries import ( get_authorized_endpoint_status, @@ -2289,45 +2290,41 @@ def get_queryset(self): return UserContactInfo.objects.all().order_by("id") -# Authorization: superuser/global-owner (all tokens) or self (own token only) -class ApiTokenViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): +# Authorization: superuser/global-owner only +class RevokeApiTokenView(GenericAPIView): """ - Manage API tokens. Tokens are looked up by the owner's user ID. - - Superusers and Global Owners can list, retrieve, and revoke tokens for any user. - Regular users can only list, retrieve, and revoke their own token. + Revoke an API token by its key value. - Revoking a token (DELETE) immediately invalidates it. The user must generate - a new token via the UI or POST /api/v2/users/{id}/reset_api_token/ to regain access. + Accepts {"key": ""} and immediately invalidates the matching token. + Intended for incident response when a token is discovered to be leaked + and the owning user may not be known. - Token expiry is managed via the user_contact_infos endpoint. + Only superusers and Global Owners may call this endpoint. """ - serializer_class = serializers.ApiTokenSerializer - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["user_id"] - lookup_field = "user_id" - permission_classes = (IsAuthenticated,) - - def get_queryset(self): - qs = Token.objects.select_related("user", "user__usercontactinfo").order_by("user_id") - if not user_is_superuser_or_global_owner(self.request.user): - qs = qs.filter(user=self.request.user) - return qs + permission_classes = (permissions.IsSuperUserOrGlobalOwner,) + pagination_class = None - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - uci = getattr(instance.user, "usercontactinfo", None) + @extend_schema( + request={"application/json": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]}}, + responses={204: None}, + summary="Revoke an API token by its key value", + ) + def post(self, request, *args, **kwargs): + key = request.data.get("key") + if not key: + return Response({"detail": "key is required."}, status=status.HTTP_400_BAD_REQUEST) + try: + token = Token.objects.select_related("user", "user__usercontactinfo").get(key=key) + except Token.DoesNotExist: + msg = "No token matching the provided key." + raise NotFound(msg) + uci = getattr(token.user, "usercontactinfo", None) if uci: uci.token_expiry = None uci.save(update_fields=["token_expiry"]) - self.perform_destroy(instance) + token.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/dojo/urls.py b/dojo/urls.py index 49d4f211ba7..4219499c017 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -13,7 +13,6 @@ from dojo.announcement.urls import urlpatterns as announcement_urls from dojo.api_v2.views import ( AnnouncementViewSet, - ApiTokenViewSet, AppAnalysisViewSet, BurpRawRequestResponseViewSet, CeleryViewSet, @@ -42,6 +41,7 @@ ProductViewSet, RegulationsViewSet, ReImportScanView, + RevokeApiTokenView, RiskAcceptanceViewSet, SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, @@ -156,7 +156,6 @@ v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") -v2_api.register(r"api-tokens", ApiTokenViewSet, basename="api-token") v2_api.register(r"users", UsersViewSet, basename="user") v2_api.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") # Add the location routes @@ -216,6 +215,7 @@ # Django Rest Framework API v2 re_path(r"^{}api/v2/".format(get_system_setting("url_prefix")), include(v2_api.urls)), re_path(r"^{}api/v2/user_profile/".format(get_system_setting("url_prefix")), UserProfileView.as_view(), name="user_profile"), + re_path(r"^{}api/v2/api-tokens/revoke/".format(get_system_setting("url_prefix")), RevokeApiTokenView.as_view(), name="api-token-revoke"), ] if hasattr(settings, "API_TOKENS_ENABLED") and hasattr(settings, "API_TOKEN_AUTH_ENDPOINT_ENABLED"): diff --git a/unittests/test_apiv2_token.py b/unittests/test_apiv2_token.py index 2988647dff5..82bbb9f36b6 100644 --- a/unittests/test_apiv2_token.py +++ b/unittests/test_apiv2_token.py @@ -13,7 +13,7 @@ @versioned_fixtures class ApiTokenTest(APITestCase): - """Test the ApiToken APIv2 endpoint.""" + """Test API token expiry enforcement and the revoke-by-key endpoint.""" fixtures = ["dojo_testdata.json"] @@ -39,61 +39,43 @@ def _client_for(self, token_key): client.credentials(HTTP_AUTHORIZATION="Token " + token_key) return client - def test_api_token_list_superuser_sees_all(self): - r = self.client.get(reverse("api-token-list")) - self.assertEqual(r.status_code, 200, r.content[:1000]) - results = r.json()["results"] - self.assertGreaterEqual(len(results), 1) - for item in results: - for field in ["user_id", "username", "created", "expiry"]: - self.assertIn(field, item) - self.assertNotIn("key", item) - - def test_api_token_list_non_superuser_sees_only_own(self): - user, token, _ = self._create_user("api-token-list-user") - client = self._client_for(token.key) - - r = client.get(reverse("api-token-list")) - self.assertEqual(r.status_code, 200, r.content[:1000]) - results = r.json()["results"] - self.assertEqual(len(results), 1) - self.assertEqual(results[0]["user_id"], user.id) - - def test_api_token_retrieve_other_user_non_superuser_returns_404(self): - _user1, token1, _ = self._create_user("api-token-retrieve-user1") - user2, _, _ = self._create_user("api-token-retrieve-user2") - client = self._client_for(token1.key) + def _revoke_url(self): + return reverse("api-token-revoke") - r = client.get("{}{}/".format(reverse("api-token-list"), user2.id)) - self.assertEqual(r.status_code, 404, r.content[:1000]) - - def test_api_token_revoke_as_superuser(self): - user, _, _ = self._create_user("api-token-revoke-super") - - r = self.client.delete("{}{}/".format(reverse("api-token-list"), user.id)) - self.assertEqual(r.status_code, 204, r.content[:1000]) - self.assertFalse(Token.objects.filter(user=user).exists()) + # --- revoke - def test_api_token_revoke_own(self): - user, token, _ = self._create_user("api-token-revoke-self") - client = self._client_for(token.key) + def test_revoke_by_key_as_superuser(self): + user, token, _ = self._create_user("api-token-revoke-super") - r = client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + r = self.client.post(self._revoke_url(), {"key": token.key}, format="json") self.assertEqual(r.status_code, 204, r.content[:1000]) self.assertFalse(Token.objects.filter(user=user).exists()) - def test_api_token_revoke_clears_expiry(self): - user, _, _ = self._create_user("api-token-revoke-expiry") + def test_revoke_by_key_clears_expiry(self): + user, token, _ = self._create_user("api-token-revoke-expiry") uci, _ = UserContactInfo.objects.get_or_create(user=user) uci.token_expiry = timezone.now() + timedelta(days=30) uci.save(update_fields=["token_expiry"]) - r = self.client.delete("{}{}/".format(reverse("api-token-list"), user.id)) + r = self.client.post(self._revoke_url(), {"key": token.key}, format="json") self.assertEqual(r.status_code, 204, r.content[:1000]) uci.refresh_from_db() self.assertIsNone(uci.token_expiry) + def test_revoke_unknown_key_returns_404(self): + r = self.client.post(self._revoke_url(), {"key": "notarealtoken"}, format="json") + self.assertEqual(r.status_code, 404, r.content[:1000]) + + def test_revoke_by_key_non_superuser_forbidden(self): + _user, token, _ = self._create_user("api-token-revoke-nonsuperuser") + client = self._client_for(token.key) + + r = client.post(self._revoke_url(), {"key": token.key}, format="json") + self.assertEqual(r.status_code, 403, r.content[:1000]) + + # --- expiry enforcement --- + def test_expired_token_rejected(self): user, token, _ = self._create_user("api-token-expired") uci, _ = UserContactInfo.objects.get_or_create(user=user) From ebbbc31ec297c74d03b1ca698f6e8aa5d58510c3 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Sun, 31 May 2026 14:44:05 -0500 Subject: [PATCH 9/9] docs: update token management section after change to revocation and expiry processes --- docs/content/automation/api/api-v2-docs.md | 36 +++++----------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/docs/content/automation/api/api-v2-docs.md b/docs/content/automation/api/api-v2-docs.md index de6345e00b9..5fe1012e10b 100644 --- a/docs/content/automation/api/api-v2-docs.md +++ b/docs/content/automation/api/api-v2-docs.md @@ -49,43 +49,23 @@ Or only `api/v2/api-token-auth/` endpoint can be disabled by setting `DD_API_TOK ### Token management -DefectDojo provides API endpoints to list, inspect, and revoke API tokens programmatically. +DefectDojo provides API endpoints to revoke and set expiry on API tokens programmatically. -#### Listing tokens +#### Revoking a token by key -Superusers and Global Owners can list all active tokens across all users. Regular users can only see their own token. +When a token value is compromised, a superuser or Global Owner can revoke it directly by its key, without needing to know which user it belongs to: ``` -GET /api/v2/api-tokens/ +POST /api/v2/api-tokens/revoke/ Authorization: Token -``` - -Response fields: `user_id`, `username`, `created`, `expiry` (null if the token does not expire). - -To filter to a specific user: - -``` -GET /api/v2/api-tokens/?user_id=5 -``` - -#### Retrieving a token - -``` -GET /api/v2/api-tokens/{user_id}/ -``` - -Superusers can retrieve any user's token. Regular users are restricted to their own user ID. Any attempt by a regular user to retrieve another user's ID is met with a 404. - -#### Revoking a token +Content-Type: application/json -``` -DELETE /api/v2/api-tokens/{user_id}/ -Authorization: Token +{"key": ""} ``` -Returns 204 on success. The token is immediately invalidated. The user will need to generate a new token via the UI (`/api_v2_key/`) or via `POST /api/v2/users/{id}/reset_api_token/` before they can authenticate again. +Returns 204 on success. The token is immediately revoked and the expiry date is cleared. The owner will need to generate a new token via the UI (`/api/key-v2`) or via `POST /api/v2/users/{id}/reset_api_token/` before they can authenticate again. -Like retrieval, superuser can revoke any user's token. Regular users can only revoke their own. +Returns 404 if no token matches the supplied key, 400 if `key` is missing, and 403 for non-superusers. #### Token expiry