Skip to content

Commit f3a93ce

Browse files
api tokens: allow admins to reset user tokens (#13885)
* apiv2: allow admins to reset tokens * token reset: send notification upon reset * token reset: rename method * revert ruff shenanigans * add ui elements * cleanup * openapi type hints
1 parent 8869737 commit f3a93ce

15 files changed

Lines changed: 513 additions & 19 deletions

File tree

dojo/api_v2/permissions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
user_has_configuration_permission,
1313
user_has_global_permission,
1414
user_has_permission,
15+
user_is_superuser_or_global_owner,
1516
)
1617
from dojo.authorization.roles_permissions import Permissions
1718
from dojo.importers.auto_create_context import AutoCreateContextManager
@@ -872,6 +873,11 @@ def has_permission(self, request, view):
872873
return request.user and request.user.is_superuser
873874

874875

876+
class IsSuperUserOrGlobalOwner(permissions.BasePermission):
877+
def has_permission(self, request, view):
878+
return user_is_superuser_or_global_owner(request.user)
879+
880+
875881
class UserHasEngagementPresetPermission(permissions.BasePermission):
876882
def has_permission(self, request, view):
877883
return check_post_permission(

dojo/api_v2/serializers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,8 @@ class UserSerializer(serializers.ModelSerializer):
487487
date_joined = serializers.DateTimeField(read_only=True)
488488
last_login = serializers.DateTimeField(read_only=True, allow_null=True)
489489
email = serializers.EmailField(required=True)
490+
token_last_reset = serializers.SerializerMethodField()
491+
password_last_reset = serializers.SerializerMethodField()
490492
password = serializers.CharField(
491493
write_only=True,
492494
style={"input_type": "password"},
@@ -515,10 +517,22 @@ class Meta:
515517
"last_login",
516518
"is_active",
517519
"is_superuser",
520+
"token_last_reset",
521+
"password_last_reset",
518522
"password",
519523
"configuration_permissions",
520524
)
521525

526+
@extend_schema_field(serializers.DateTimeField(allow_null=True))
527+
def get_token_last_reset(self, instance):
528+
uci = getattr(instance, "usercontactinfo", None)
529+
return getattr(uci, "token_last_reset", None)
530+
531+
@extend_schema_field(serializers.DateTimeField(allow_null=True))
532+
def get_password_last_reset(self, instance):
533+
uci = getattr(instance, "usercontactinfo", None)
534+
return getattr(uci, "password_last_reset", None)
535+
522536
def to_representation(self, instance):
523537
ret = super().to_representation(instance)
524538

dojo/api_v2/views.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
from dojo.risk_acceptance.queries import get_authorized_risk_acceptances
172172
from dojo.test.queries import get_authorized_test_imports, get_authorized_tests
173173
from dojo.tool_product.queries import get_authorized_tool_product_settings
174+
from dojo.user.authentication import reset_token_for_user
174175
from dojo.user.utils import get_configuration_permissions_codenames
175176
from dojo.utils import (
176177
async_delete,
@@ -2410,6 +2411,19 @@ def destroy(self, request, *args, **kwargs):
24102411
self.perform_destroy(instance)
24112412
return Response(status=status.HTTP_204_NO_CONTENT)
24122413

2414+
@action(
2415+
detail=True,
2416+
methods=["post"],
2417+
url_path="reset_api_token",
2418+
permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner),
2419+
filter_backends=[],
2420+
pagination_class=None,
2421+
)
2422+
def reset_api_token(self, request, pk=None):
2423+
target_user = self.get_object()
2424+
reset_token_for_user(acting_user=request.user, target_user=target_user)
2425+
return Response(status=status.HTTP_204_NO_CONTENT)
2426+
24132427

24142428
# Authorization: superuser
24152429
@extend_schema_view(**schema_with_prefetch())

dojo/authorization/authorization.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ def user_has_configuration_permission(user, permission):
4040
return user.has_perm(permission)
4141

4242

43+
def user_is_superuser_or_global_owner(user):
44+
"""
45+
Returns True if the user is a superuser or has a global role (directly or
46+
via group membership) whose Role.is_owner is True.
47+
"""
48+
if not user or getattr(user, "is_anonymous", False):
49+
return False
50+
51+
if user.is_superuser:
52+
return True
53+
54+
if (
55+
hasattr(user, "global_role")
56+
and user.global_role.role is not None
57+
and user.global_role.role.is_owner
58+
):
59+
return True
60+
61+
for group in get_groups(user):
62+
if (
63+
hasattr(group, "global_role")
64+
and group.global_role.role is not None
65+
and group.global_role.role.is_owner
66+
):
67+
return True
68+
69+
return False
70+
71+
4372
def user_has_permission(user, obj, permission):
4473
if user.is_anonymous:
4574
return False
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 3.1.13 on 2025-12-12
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("dojo", "0248_alter_general_survey_expiration"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="usercontactinfo",
15+
name="token_last_reset",
16+
field=models.DateTimeField(
17+
blank=True,
18+
help_text="Timestamp of the most recent API token reset for this user.",
19+
null=True,
20+
),
21+
),
22+
migrations.AddField(
23+
model_name="usercontactinfo",
24+
name="password_last_reset",
25+
field=models.DateTimeField(
26+
blank=True,
27+
help_text="Timestamp of the most recent password reset for this user.",
28+
null=True,
29+
),
30+
),
31+
]
32+
33+

dojo/forms.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from tagulous.forms import TagField
3232

3333
import dojo.jira_link.helper as jira_helper
34-
from dojo.authorization.authorization import user_has_configuration_permission
34+
from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner
3535
from dojo.authorization.roles_permissions import Permissions
3636
from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add
3737
from dojo.engagement.queries import get_authorized_engagements
@@ -2420,13 +2420,32 @@ class Meta:
24202420

24212421

24222422
class UserContactInfoForm(forms.ModelForm):
2423+
reset_api_token = forms.BooleanField(
2424+
required=False,
2425+
label=_("Reset API token"),
2426+
help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."),
2427+
)
2428+
24232429
class Meta:
24242430
model = UserContactInfo
24252431
exclude = ["user", "slack_user_id"]
2432+
# Swap order: password_last_reset before token_last_reset
2433+
field_order = [
2434+
"title", "phone_number", "cell_number", "twitter_username", "github_username",
2435+
"slack_username", "block_execution", "force_password_reset", "reset_api_token",
2436+
"password_last_reset", "token_last_reset",
2437+
]
24262438

24272439
def __init__(self, *args, **kwargs):
24282440
user = kwargs.pop("user", None)
24292441
super().__init__(*args, **kwargs)
2442+
# Make timestamp fields readonly.
2443+
# NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields
2444+
# are ignored during binding/cleaning, so these timestamps cannot be modified via this form.
2445+
if "password_last_reset" in self.fields:
2446+
self.fields["password_last_reset"].disabled = True
2447+
if "token_last_reset" in self.fields:
2448+
self.fields["token_last_reset"].disabled = True
24302449
# Do not expose force password reset if the current user does not have a password to reset
24312450
if user is not None:
24322451
if not user.has_usable_password():
@@ -2437,11 +2456,15 @@ def __init__(self, *args, **kwargs):
24372456
if not current_user.is_superuser:
24382457
if not user_has_configuration_permission(current_user, "auth.change_user") and \
24392458
not user_has_configuration_permission(current_user, "auth.add_user"):
2440-
del self.fields["force_password_reset"]
2459+
self.fields.pop("force_password_reset", None)
24412460
if not get_system_setting("enable_user_profile_editable"):
24422461
for field in self.fields:
24432462
self.fields[field].disabled = True
24442463

2464+
# Only show reset_api_token to superusers or global owners, and only if API tokens are enabled
2465+
if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user):
2466+
self.fields.pop("reset_api_token", None)
2467+
24452468

24462469
class GlobalRoleForm(forms.ModelForm):
24472470
class Meta:
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.contrib.auth import get_user_model
2+
from django.core.management.base import BaseCommand, CommandError
3+
4+
from dojo.user.authentication import reset_token_for_user
5+
6+
7+
class Command(BaseCommand):
8+
help = "Rotate (reset) the DRF API token for a target user. Requires an acting user (superuser or global owner)."
9+
10+
def add_arguments(self, parser):
11+
parser.add_argument("--acting-user", required=True, help="Username of the acting user performing the reset.")
12+
parser.add_argument("--username", help="Username of the target user.")
13+
parser.add_argument("--user-id", type=int, help="ID of the target user.")
14+
15+
def handle(self, *args, **options):
16+
User = get_user_model()
17+
18+
acting_username = options["acting_user"]
19+
target_username = options.get("username")
20+
target_user_id = options.get("user_id")
21+
22+
if bool(target_username) == bool(target_user_id):
23+
msg = "Provide exactly one of --username or --user-id."
24+
raise CommandError(msg)
25+
26+
try:
27+
acting_user = User.objects.get(username=acting_username)
28+
except User.DoesNotExist as exc:
29+
msg = f"Acting user '{acting_username}' does not exist."
30+
raise CommandError(msg) from exc
31+
32+
try:
33+
if target_username:
34+
target_user = User.objects.get(username=target_username)
35+
else:
36+
target_user = User.objects.get(id=target_user_id)
37+
except User.DoesNotExist as exc:
38+
msg = "Target user does not exist."
39+
raise CommandError(msg) from exc
40+
41+
try:
42+
reset_token_for_user(acting_user=acting_user, target_user=target_user)
43+
except Exception as exc:
44+
raise CommandError(str(exc)) from exc
45+
46+
self.stdout.write(self.style.SUCCESS("API token reset successfully."))

dojo/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ class UserContactInfo(models.Model):
268268
slack_user_id = models.CharField(blank=True, null=True, max_length=25)
269269
block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion."))
270270
force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login."))
271+
token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user."))
272+
password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user."))
271273

272274

273275
class Dojo_Group(models.Model):

dojo/templates/dojo/users.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ <h3 class="has-filters">
6969
<th class="nowrap">{% trans "Global Role" %}</th>
7070
<th class="nowrap">{% trans "Date Joined" %}</th>
7171
<th class="nowrap">{% trans "Last Login" %}</th>
72+
<th class="nowrap">{% trans "Token Last Reset" %}</th>
73+
<th class="nowrap">{% trans "Password Last Reset" %}</th>
7274
{% block users_table_extra_header_rows %}
7375
{% endblock users_table_extra_header_rows %}
7476
</tr>
@@ -127,6 +129,8 @@ <h3 class="has-filters">
127129
<td>{% if u.global_role.role %} {{ u.global_role.role }} {% endif %}</td>
128130
<td>{{ u.date_joined }}</td>
129131
<td>{% if u.last_login %}{{ u.last_login }}{% else %}{% trans "Never" %}{% endif %}</td>
132+
<td>{% if u.usercontactinfo.token_last_reset %}{{ u.usercontactinfo.token_last_reset }}{% else %}{% trans "Never" %}{% endif %}</td>
133+
<td>{% if u.usercontactinfo.password_last_reset %}{{ u.usercontactinfo.password_last_reset }}{% else %}{% trans "Never" %}{% endif %}</td>
130134
{% block users_table_extra_data_rows %}
131135
{% endblock users_table_extra_data_rows %}
132136
</tr>

dojo/templates/dojo/view_user.html

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ <h3 class="pull-left">{% trans "Default Information" %}</h3>
3434
{% if "auth.delete_user"|has_configuration_permission:request and user.id != request.user.id %}
3535
<li role="separator" class="divider"></li>
3636
<li>
37-
<a class="" href="{% url 'delete_user' user.id %}" id="deleteUser">
37+
<a class="" href="{% url 'delete_user' user.id %}" id="deleteUser">
3838
<i class="fa-solid fa-trash"></i>{% trans "Delete" %}</a>
3939
</li>
4040
{% endif %}
@@ -374,6 +374,14 @@ <h3 class="panel-title"><span class="fa-solid fa-circle-info fa-fw" aria-hidden=
374374
<td style="width: 200px;"><strong>{% trans "Last Login" %}</strong></td>
375375
<td>{% if user.last_login %} {{ user.last_login }} {% else %} {% trans "Never" %} {% endif %}</td>
376376
</tr>
377+
<tr>
378+
<td style="width: 200px;"><strong>{% trans "Token Last Reset" %}</strong></td>
379+
<td>{% if user.usercontactinfo.token_last_reset %} {{ user.usercontactinfo.token_last_reset }} {% else %} {% trans "Never" %} {% endif %}</td>
380+
</tr>
381+
<tr>
382+
<td style="width: 200px;"><strong>{% trans "Password Last Reset" %}</strong></td>
383+
<td>{% if user.usercontactinfo.password_last_reset %} {{ user.usercontactinfo.password_last_reset }} {% else %} {% trans "Never" %} {% endif %}</td>
384+
</tr>
377385
</tbody>
378386
</table>
379387
</div>
@@ -399,11 +407,11 @@ <h3 class="panel-title"><span class="fa-solid fa-lock-open-alt" aria-hidden="tru
399407
<td><strong>{{ field.display_name }}</strong></td>
400408
<td class="centered">
401409
{% if field.view_codename %}
402-
<input id="id_{{ field.view_codename }}"
410+
<input id="id_{{ field.view_codename }}"
403411
{% if not request.user.is_superuser %}disabled{% endif %}
404-
name="{{ field.view_codename }}"
405-
aria-label="{{ field.view_codename }}"
406-
type="checkbox"
412+
name="{{ field.view_codename }}"
413+
aria-label="{{ field.view_codename }}"
414+
type="checkbox"
407415
{% if user|user_has_configuration_permission_without_group:field.view_codename %}checked{% endif %} />
408416
{% endif %}
409417
</td>

0 commit comments

Comments
 (0)