Skip to content

Commit 649a2da

Browse files
committed
feat(accounts): include API token reset on password change
When the password change is triggered by a compromise user might not realize that the API token might be compromised as well.
1 parent e1eff1f commit 649a2da

7 files changed

Lines changed: 117 additions & 19 deletions

File tree

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Weblate 5.17.1
1010
.. rubric:: Improvements
1111

1212
* Clarified the site-wide scope of the global ``user.edit`` permission.
13+
* Password updates now regenerate your personal API key by default.
1314
* The web installation flow for :ref:`addon-weblate.consistency.languages` now shows a preview and requires confirmation before creating missing language files across projects, categories, or site-wide scopes.
1415
* Improved :ref:`addon-weblate.discovery.discovery` guidance with guided client-side presets, clearer ``{{ component }}`` validation, and a worked discovery-template example in the docs.
1516
* Admins can now revert edits from blocked users in a project or from any user site-wide.

weblate/accounts/forms.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
cycle_session_keys,
4040
get_all_user_mails,
4141
invalidate_reset_codes,
42+
reset_api_token,
4243
)
4344
from weblate.auth.models import Group, User
4445
from weblate.lang.models import Language
@@ -723,6 +724,16 @@ class SetPasswordForm(DjangoSetPasswordForm):
723724
label=gettext_lazy("New password confirmation"),
724725
new_password=True,
725726
)
727+
regenerate_api_key = forms.BooleanField(
728+
label=gettext_lazy("Regenerate API key"),
729+
help_text=gettext_lazy(
730+
"Leave enabled to revoke the current API key and generate a new one. "
731+
"This is recommended if you suspect your password was compromised. "
732+
"Disable it to keep your current API key active after changing your password."
733+
),
734+
required=False,
735+
initial=True,
736+
)
726737

727738
@transaction.atomic
728739
# pylint: disable-next=arguments-renamed
@@ -746,6 +757,9 @@ def save(self, request: AuthenticatedHttpRequest, delete_session=False) -> None:
746757
# Invalidate password reset codes
747758
invalidate_reset_codes(self.user)
748759

760+
if self.cleaned_data.get("regenerate_api_key"):
761+
reset_api_token(self.user)
762+
749763
if delete_session:
750764
request.session.flush()
751765

weblate/accounts/models.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
from django_otp.plugins.otp_static.models import StaticDevice
3232
from django_otp.plugins.otp_totp.models import TOTPDevice
3333
from django_otp_webauthn.models import WebAuthnCredential
34-
from rest_framework.authtoken.models import Token
3534
from social_django.models import UserSocialAuth
3635
from unidecode import unidecode
3736

@@ -60,7 +59,6 @@
6059
GhostProjectLanguageStats,
6160
ProjectLanguageStats,
6261
)
63-
from weblate.utils.token import get_token
6462
from weblate.utils.validators import EMAIL_BLACKLIST, WeblateURLValidator
6563
from weblate.wladmin.models import get_support_status
6664

@@ -1345,10 +1343,10 @@ def post_login_handler(
13451343
def create_profile_callback(sender, instance, created=False, **kwargs) -> None:
13461344
"""Automatically create token and profile for user."""
13471345
if created:
1346+
from weblate.accounts.utils import create_api_token # noqa: PLC0415
1347+
13481348
# Create API token
1349-
instance.auth_token = Token.objects.create(
1350-
user=instance, key=get_token("wlp" if instance.is_bot else "wlu")
1351-
)
1349+
instance.auth_token = create_api_token(instance)
13521350
# Create profile
13531351
instance.profile = Profile.objects.create(user=instance)
13541352
# Create subscriptions

weblate/accounts/tests/test_registration.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from django.test import Client, TestCase
1818
from django.test.utils import modify_settings, override_settings
1919
from django.urls import reverse
20+
from rest_framework.authtoken.models import Token
2021

2122
from weblate.accounts.captcha import solve_altcha
2223
from weblate.accounts.models import VerifiedEmail
@@ -105,7 +106,11 @@ def assert_registration(self, match=None, reset=False):
105106
# Set password
106107
response = self.client.post(
107108
reverse("password_reset"),
108-
{"new_password1": "2pa$$word!", "new_password2": "2pa$$word!"},
109+
{
110+
"new_password1": "2pa$$word!",
111+
"new_password2": "2pa$$word!",
112+
"regenerate_api_key": "on",
113+
},
109114
follow=True,
110115
)
111116
self.assertContains(response, "Your password has been changed")
@@ -135,7 +140,11 @@ def perform_registration(self) -> None:
135140
# Set password
136141
response = self.client.post(
137142
reverse("password"),
138-
{"new_password1": "1pa$$word!", "new_password2": "1pa$$word!"},
143+
{
144+
"new_password1": "1pa$$word!",
145+
"new_password2": "1pa$$word!",
146+
"regenerate_api_key": "on",
147+
},
139148
)
140149
self.assertRedirects(response, reverse("profile"))
141150
# Password change notification
@@ -424,15 +433,23 @@ def test_reset_parallel(self) -> None:
424433
# Set first password
425434
response = self.client.post(
426435
reverse("password_reset"),
427-
{"new_password1": "2pa$$word!", "new_password2": "2pa$$word!"},
436+
{
437+
"new_password1": "2pa$$word!",
438+
"new_password2": "2pa$$word!",
439+
"regenerate_api_key": "on",
440+
},
428441
follow=True,
429442
)
430443
self.assertContains(response, "Your password has been changed")
431444

432445
# Set second password
433446
response = client2.post(
434447
reverse("password_reset"),
435-
{"new_password1": "3pa$$word!", "new_password2": "3pa$$word!"},
448+
{
449+
"new_password1": "3pa$$word!",
450+
"new_password2": "3pa$$word!",
451+
"regenerate_api_key": "on",
452+
},
436453
follow=True,
437454
)
438455
self.assertContains(response, "Password reset has been already completed.")
@@ -797,7 +814,8 @@ def test_double_link(self) -> None:
797814
@override_settings(REGISTRATION_CAPTCHA=False)
798815
def test_reset(self) -> None:
799816
"""Test for password reset."""
800-
User.objects.create_user("testuser", "test@example.com", "x")
817+
user = User.objects.create_user("testuser", "test@example.com", "x")
818+
old_token = user.auth_token.key
801819

802820
response = self.client.get(reverse("password_reset"))
803821
self.assertContains(response, "Reset my password")
@@ -807,6 +825,34 @@ def test_reset(self) -> None:
807825
self.assertContains(response, "Password reset almost complete")
808826

809827
self.assert_registration(reset=True)
828+
self.assertNotEqual(Token.objects.get(user=user).key, old_token)
829+
830+
@override_settings(REGISTRATION_CAPTCHA=False)
831+
def test_reset_keeps_api_key(self) -> None:
832+
"""Test for password reset without API key regeneration."""
833+
user = User.objects.create_user("testuser", "test@example.com", "x")
834+
old_token = user.auth_token.key
835+
836+
response = self.client.post(
837+
reverse("password_reset"), {"email": "test@example.com"}, follow=True
838+
)
839+
self.assertContains(response, "Password reset almost complete")
840+
841+
response = self.client.get(
842+
self.assert_registration_mailbox("[Weblate] Password reset on Weblate"),
843+
follow=True,
844+
)
845+
self.assertRedirects(response, reverse("password_reset"))
846+
self.assertContains(response, "You can now set new one")
847+
self.assertContains(response, "Regenerate API key")
848+
849+
response = self.client.post(
850+
reverse("password_reset"),
851+
{"new_password1": "2pa$$word!", "new_password2": "2pa$$word!"},
852+
follow=True,
853+
)
854+
self.assertContains(response, "Your password has been changed")
855+
self.assertEqual(Token.objects.get(user=user).key, old_token)
810856

811857

812858
class NoCookieRegistrationTest(CookieRegistrationTest):

weblate/accounts/tests/test_views.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from django.urls import reverse
1818
from jsonschema import validate
1919
from requests.exceptions import HTTPError
20+
from rest_framework.authtoken.models import Token
2021
from social_core.exceptions import (
2122
AuthCanceled,
2223
AuthFailed,
@@ -538,7 +539,8 @@ def test_login_ratelimit_login(self) -> None:
538539

539540
def test_password(self) -> None:
540541
# Create user
541-
self.get_user()
542+
user = self.get_user()
543+
old_token = user.auth_token.key
542544
# Login
543545
self.client.login(username="testuser", password="testpassword")
544546
# Change without data
@@ -563,14 +565,35 @@ def test_password(self) -> None:
563565
"password": "testpassword",
564566
"new_password1": "1pa$$word!",
565567
"new_password2": "1pa$$word!",
568+
"regenerate_api_key": "on",
566569
},
567570
)
568571

569572
self.assertRedirects(response, f"{reverse('profile')}#account")
570-
self.assertTrue(
571-
User.objects.get(username="testuser").check_password("1pa$$word!")
573+
updated_user = User.objects.get(username="testuser")
574+
self.assertTrue(updated_user.check_password("1pa$$word!"))
575+
self.assertNotEqual(updated_user.auth_token.key, old_token)
576+
self.assertFalse(Token.objects.filter(key=old_token).exists())
577+
578+
def test_password_keeps_api_key(self) -> None:
579+
user = self.get_user()
580+
old_token = user.auth_token.key
581+
582+
self.client.login(username="testuser", password="testpassword")
583+
response = self.client.post(
584+
reverse("password"),
585+
{
586+
"password": "testpassword",
587+
"new_password1": "1pa$$word!",
588+
"new_password2": "1pa$$word!",
589+
},
572590
)
573591

592+
self.assertRedirects(response, f"{reverse('profile')}#account")
593+
updated_user = User.objects.get(username="testuser")
594+
self.assertTrue(updated_user.check_password("1pa$$word!"))
595+
self.assertEqual(updated_user.auth_token.key, old_token)
596+
574597
def test_api_key(self) -> None:
575598
# Create user
576599
user = self.get_user()

weblate/accounts/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from weblate.accounts.models import AuditLog, VerifiedEmail
2222
from weblate.auth.models import User
2323
from weblate.trans.signals import user_pre_delete
24+
from weblate.utils.token import get_token
2425

2526
if TYPE_CHECKING:
2627
from django_otp.models import Device
@@ -37,6 +38,24 @@
3738
SECOND_FACTOR_VERIFY_SECONDS = 600
3839

3940

41+
def create_api_token(user: User) -> Token:
42+
"""Create an API token for a user."""
43+
return Token.objects.create(
44+
user=user, key=get_token("wlp" if user.is_bot else "wlu")
45+
)
46+
47+
48+
def delete_api_tokens(user: User) -> None:
49+
"""Delete all API tokens for a user."""
50+
Token.objects.filter(user=user).delete()
51+
52+
53+
def reset_api_token(user: User) -> Token:
54+
"""Reset API token for a user."""
55+
delete_api_tokens(user)
56+
return create_api_token(user)
57+
58+
4059
def remove_user(
4160
user: User,
4261
request: AuthenticatedHttpRequest | None,
@@ -103,7 +122,7 @@ def remove_user(
103122
profile.save()
104123

105124
# Delete API tokens
106-
Token.objects.filter(user=user).delete()
125+
delete_api_tokens(user)
107126

108127

109128
def lock_user(

weblate/accounts/views.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
CompleteCredentialAuthenticationView,
7070
)
7171
from requests.exceptions import HTTPError
72-
from rest_framework.authtoken.models import Token
7372
from social_core.actions import do_auth
7473
from social_core.backends.base import BaseAuth
7574
from social_core.exceptions import (
@@ -136,6 +135,7 @@
136135
get_key_name,
137136
lock_user,
138137
remove_user,
138+
reset_api_token,
139139
)
140140
from weblate.auth.forms import UserEditForm
141141
from weblate.auth.models import Invitation, User, get_anonymous
@@ -151,7 +151,6 @@
151151
from weblate.utils.ratelimit import check_rate_limit, session_ratelimit_post
152152
from weblate.utils.request import get_ip_address, get_user_agent
153153
from weblate.utils.stats import prefetch_stats
154-
from weblate.utils.token import get_token
155154
from weblate.utils.version import USER_AGENT
156155
from weblate.utils.views import get_paginator, parse_path
157156
from weblate.utils.zammad import ZammadError, submit_zammad_ticket
@@ -1265,10 +1264,8 @@ def reset_password(request: AuthenticatedHttpRequest):
12651264
@session_ratelimit_post("reset_api")
12661265
def reset_api_key(request: AuthenticatedHttpRequest):
12671266
"""Reset user API key."""
1268-
# Need to delete old token as key is primary key
12691267
with transaction.atomic():
1270-
Token.objects.filter(user=request.user).delete()
1271-
Token.objects.create(user=request.user, key=get_token("wlu"))
1268+
reset_api_token(request.user)
12721269

12731270
return redirect_profile("#api")
12741271

0 commit comments

Comments
 (0)