diff --git a/docs/content/automation/api/api-v2-docs.md b/docs/content/automation/api/api-v2-docs.md index 82fb8730765..5fe1012e10b 100644 --- a/docs/content/automation/api/api-v2-docs.md +++ b/docs/content/automation/api/api-v2-docs.md @@ -47,6 +47,54 @@ 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 revoke and set expiry on API tokens programmatically. + +#### Revoking a token by key + +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: + +``` +POST /api/v2/api-tokens/revoke/ +Authorization: Token +Content-Type: application/json + +{"key": ""} +``` + +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. + +Returns 404 if no token matches the supplied key, 400 if `key` is missing, and 403 for non-superusers. + +#### 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 `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`. + +#### 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/serializers.py b/dojo/api_v2/serializers.py index dc15ac6c2dc..1ed3ccefd4f 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -481,6 +481,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 +513,7 @@ class Meta: "is_staff", "is_superuser", "token_last_reset", + "token_expiry", "password_last_reset", "password", "configuration_permissions", @@ -522,6 +524,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) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3f8bb0cf169..c57c3f8763a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -30,7 +30,9 @@ ) 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.exceptions import NotFound from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated @@ -2288,6 +2290,44 @@ def get_queryset(self): return UserContactInfo.objects.all().order_by("id") +# Authorization: superuser/global-owner only +class RevokeApiTokenView(GenericAPIView): + + """ + Revoke an API token by its key value. + + 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. + + Only superusers and Global Owners may call this endpoint. + """ + + permission_classes = (permissions.IsSuperUserOrGlobalOwner,) + pagination_class = 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"]) + token.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + # Authorization: authenticated users class UserProfileView(GenericAPIView): permission_classes = (IsAuthenticated,) 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/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/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/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/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 index 9b9a8d6a399..4219499c017 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -41,6 +41,7 @@ ProductViewSet, RegulationsViewSet, ReImportScanView, + RevokeApiTokenView, RiskAcceptanceViewSet, SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, @@ -214,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/dojo/user/authentication.py b/dojo/user/authentication.py index 11f8fce104c..c69963fdb20 100644 --- a/dojo/user/authentication.py +++ b/dojo/user/authentication.py @@ -1,14 +1,27 @@ +from datetime import timedelta + from django.conf import settings from django.urls import reverse from django.utils import timezone +from rest_framework.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(): + msg = "API token has expired." + raise AuthenticationFailed(msg) + 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 +44,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/unittests/test_apiv2_token.py b/unittests/test_apiv2_token.py new file mode 100644 index 00000000000..82bbb9f36b6 --- /dev/null +++ b/unittests/test_apiv2_token.py @@ -0,0 +1,123 @@ +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 API token expiry enforcement and the revoke-by-key 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 _revoke_url(self): + return reverse("api-token-revoke") + + # --- revoke + + def test_revoke_by_key_as_superuser(self): + user, token, _ = self._create_user("api-token-revoke-super") + + 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_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.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) + 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, 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") + + 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)