Skip to content
48 changes: 48 additions & 0 deletions docs/content/automation/api/api-v2-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <api_key>
Content-Type: application/json

{"key": "<token_to_revoke>"}
```

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 <api_key>
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
Expand Down
7 changes: 7 additions & 0 deletions dojo/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -512,6 +513,7 @@ class Meta:
"is_staff",
"is_superuser",
"token_last_reset",
"token_expiry",
"password_last_reset",
"password",
"configuration_permissions",
Expand All @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions dojo/api_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<token>"} 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,)
Expand Down
16 changes: 16 additions & 0 deletions dojo/db_migrations/0269_usercontactinfo_token_expiry.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions dojo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."))


Expand Down
5 changes: 4 additions & 1 deletion dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions dojo/templates/dojo/api_v2_key.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ <h2> {{ name }}</h2>
<hr/>
<p>{% trans "Your current API key is" %} <code>{{ key.key }}</code></p>
<p>{% trans "Your current API Authorization Header value is" %} <code>Token {{ key.key }}</code></p>
{% if token_expiry %}
<p>{% trans "Your API key expires on" %} <strong>{{ token_expiry }}</strong></p>
{% else %}
<p>{% trans "Your API key does not expire." %}</p>
{% endif %}
<p>{% trans "Has your key been exposed? Are you ready for a new one?" %}</p>
<form action="{% url 'api_v2_key' %}" method="post" class="inline-form">
{% csrf_token %}
Expand Down
5 changes: 5 additions & 0 deletions dojo/templates_classic/dojo/api_v2_key.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ <h2> {{ name }}</h2>
<hr/>
<p>{% trans "Your current API key is" %} <code>{{ key.key }}</code></p>
<p>{% trans "Your current API Authorization Header value is" %} <code>Token {{ key.key }}</code></p>
{% if token_expiry %}
<p>{% trans "Your API key expires on" %} <strong>{{ token_expiry }}</strong></p>
{% else %}
<p>{% trans "Your API key does not expire." %}</p>
{% endif %}
<p>{% trans "Has your key been exposed? Are you ready for a new one?" %}</p>
<form action="{% url 'api_v2_key' %}" method="post" class="inline-form">
{% csrf_token %}
Expand Down
2 changes: 2 additions & 0 deletions dojo/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ProductViewSet,
RegulationsViewSet,
ReImportScanView,
RevokeApiTokenView,
RiskAcceptanceViewSet,
SLAConfigurationViewset,
SonarqubeIssueTransitionViewSet,
Expand Down Expand Up @@ -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"):
Expand Down
19 changes: 17 additions & 2 deletions dojo/user/authentication.py
Original file line number Diff line number Diff line change
@@ -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."
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions dojo/user/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,15 @@ 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",
{"name": _("API v2 Key"),
"metric": False,
"user": request.user,
"key": api_key,
"token_expiry": getattr(uci, "token_expiry", None),
"form": form,
})

Expand Down
Loading
Loading