From 903e9b93c68e7bb6009e9d003c9049197d4ffe58 Mon Sep 17 00:00:00 2001 From: BakirFS Date: Thu, 8 Oct 2026 11:07:35 +0200 Subject: [PATCH 01/11] revoke drf token after password change --- api/app/settings/common.py | 1 + .../test_unit_custom_auth_views.py | 31 +++++++++++ frontend/common/constants.ts | 3 ++ .../components/pages/AccountSettingsPage.tsx | 54 +++++++++++++------ .../components/pages/home-page/HomePage.tsx | 19 ++++++- 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 8f720b6f21f0..2e1345b4413a 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -949,6 +949,7 @@ "confirmation": "users.emails.ConfirmationEmail", }, "SET_PASSWORD_RETYPE": True, + "LOGOUT_ON_PASSWORD_CHANGE": True, "PASSWORD_RESET_CONFIRM_RETYPE": True, "HIDE_USERS": True, "PERMISSIONS": { diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index 6a20405ae839..9661f745ce43 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -7,6 +7,7 @@ from freezegun import freeze_time from pytest_django.fixtures import SettingsWrapper from rest_framework import status +from rest_framework.authtoken.models import Token from rest_framework.test import APIClient from users.models import FFAdminUser, HubspotTracker @@ -265,3 +266,33 @@ def test_create_user__no_hubspot_cookie__does_not_create_hubspot_tracker( assert user is not None mock_create_hubspot_contact_for_user.delay.assert_called_once_with(args=(user.id,)) + + +def test_set_password__token_auth__revokes_token_for_all_sessions( + staff_user: FFAdminUser, +) -> None: + # Given + staff_user.set_password("old-password") + staff_user.save() + token = Token.objects.create(user=staff_user) + current_client = APIClient() + current_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}") + other_client = APIClient() + other_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}") + url = reverse("api-v1:custom_auth:ffadminuser-set-password") + + # When + response = current_client.post( + url, + data={ + "current_password": "old-password", + "new_password": "new-password-123!", + "re_new_password": "new-password-123!", + }, + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Token.objects.filter(user=staff_user).exists() + me_url = reverse("api-v1:custom_auth:ffadminuser-me") + assert other_client.get(me_url).status_code == status.HTTP_401_UNAUTHORIZED diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index f97a6218cdb8..a93ee1e738b4 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -91,6 +91,9 @@ const keywordsReactNative = { ...keywords, NPM_CLIENT: '@flagsmith/react-native', } +// Set before logging out after a password change, so the login page can say why. +export const PASSWORD_CHANGED_SESSION_KEY = 'password_changed' + const Constants = { archivedTag: { color: '#8f8f8f', label: 'Archived' }, codeHelp: { diff --git a/frontend/web/components/pages/AccountSettingsPage.tsx b/frontend/web/components/pages/AccountSettingsPage.tsx index f57888389e87..e0dfe152ed51 100644 --- a/frontend/web/components/pages/AccountSettingsPage.tsx +++ b/frontend/web/components/pages/AccountSettingsPage.tsx @@ -23,6 +23,7 @@ import InputGroup from 'components/base/forms/InputGroup' import AppActions from 'common/dispatcher/app-actions' // @ts-ignore import Project from 'common/project' +import { PASSWORD_CHANGED_SESSION_KEY } from 'common/constants' import flagsmith from '@flagsmith/flagsmith' import { Account, AuthType } from 'common/types/responses' @@ -135,23 +136,42 @@ const AccountSettingsPage: FC = () => { ) { return } - setIsSaving(true) - _data - .post(`${Project.api}auth/users/set_password/`, { - current_password: currentPassword, - new_password: newPassword1, - re_new_password: newPassword2, - }) - .then(() => { - setIsSaving(false) - toast('Your password has been updated') - }) - .catch(() => { - setIsSaving(false) - setPasswordError( - 'There was an error setting your password, please check your details.', - ) - }) + openConfirm({ + body: ( +
+ Changing your password will{' '} + + log you out on all devices + + , including this one. You will need to log in again with your new + password. +
+ ), + onYes: () => { + setIsSaving(true) + _data + .post(`${Project.api}auth/users/set_password/`, { + current_password: currentPassword, + new_password: newPassword1, + re_new_password: newPassword2, + }) + .then(() => { + setIsSaving(false) + // Changing the password revokes the auth token on every device, + // including this one, so log out to show the login page. + sessionStorage.setItem(PASSWORD_CHANGED_SESSION_KEY, 'true') + AppActions.logout() + }) + .catch(() => { + setIsSaving(false) + setPasswordError( + 'There was an error setting your password, please check your details.', + ) + }) + }, + title: 'Change Password', + yesText: 'Change password', + }) } const forced2Factor = AccountStore.forced2Factor() diff --git a/frontend/web/components/pages/home-page/HomePage.tsx b/frontend/web/components/pages/home-page/HomePage.tsx index 930b1ade9f5d..1335ce2c9f3d 100644 --- a/frontend/web/components/pages/home-page/HomePage.tsx +++ b/frontend/web/components/pages/home-page/HomePage.tsx @@ -12,7 +12,7 @@ import ForgotPasswordModal from 'components/modals/ForgotPasswordModal' import Card from 'components/Card' import NavIconSmall from 'components/icons/NavIconSmall' import ConfigProvider from 'common/providers/ConfigProvider' -import Constants from 'common/constants' +import Constants, { PASSWORD_CHANGED_SESSION_KEY } from 'common/constants' import ErrorMessage from 'components/ErrorMessage' import Button from 'components/base/forms/Button' import PasswordRequirements from 'components/PasswordRequirements' @@ -109,6 +109,12 @@ const HomePage: React.FC = () => { // component, so '' would match a login error that arrived before any signup. const [submittedEmail, setSubmittedEmail] = useState(null) const [emailAlreadyRegistered, setEmailAlreadyRegistered] = useState(false) + const [passwordChanged] = useState( + () => sessionStorage.getItem(PASSWORD_CHANGED_SESSION_KEY) === 'true', + ) + useEffect(() => { + sessionStorage.removeItem(PASSWORD_CHANGED_SESSION_KEY) + }, []) // Set by EmailActivationPage on the redirect it makes here. const isGettingStarted = !!(location.state as LoginLocationState) ?.isGettingStarted @@ -496,6 +502,17 @@ const HomePage: React.FC = () => {

)} + {passwordChanged && ( +
+ + + +

+ Your password has been changed, log in + again to continue +

+
+ )} {isInvite && !emailAlreadyRegistered && (
From f91558b94a577022f4197a53d3d48d9bd52e74b1 Mon Sep 17 00:00:00 2001 From: BakirFS Date: Thu, 8 Oct 2026 11:23:30 +0200 Subject: [PATCH 02/11] typecheck --- api/tests/unit/custom_auth/test_unit_custom_auth_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index 9661f745ce43..c5f48d803279 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from django.contrib.auth.hashers import make_password from django.urls import reverse from freezegun import freeze_time from pytest_django.fixtures import SettingsWrapper @@ -272,7 +273,7 @@ def test_set_password__token_auth__revokes_token_for_all_sessions( staff_user: FFAdminUser, ) -> None: # Given - staff_user.set_password("old-password") + staff_user.password = make_password("old-password") staff_user.save() token = Token.objects.create(user=staff_user) current_client = APIClient() From 64df31fb341233ed6a95d1666e4a199d6d1ea4df Mon Sep 17 00:00:00 2001 From: BakirFS Date: Mon, 12 Oct 2026 11:22:55 +0200 Subject: [PATCH 03/11] revoke drf token on email password reset Djoser's LOGOUT_ON_PASSWORD_CHANGE only applies to set_password, so reset_password_confirm left existing tokens valid. Delete them in FFAdminUser.set_password, which every password change goes through. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test_unit_custom_auth_views.py | 29 +++++++++++++++++++ .../unit/users/test_unit_users_models.py | 14 +++++++++ api/users/models.py | 2 ++ 3 files changed, 45 insertions(+) diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index c5f48d803279..0f7e198dc106 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -4,7 +4,9 @@ import pytest from django.contrib.auth.hashers import make_password +from django.contrib.auth.tokens import default_token_generator from django.urls import reverse +from djoser import utils # type: ignore[import-untyped] from freezegun import freeze_time from pytest_django.fixtures import SettingsWrapper from rest_framework import status @@ -297,3 +299,30 @@ def test_set_password__token_auth__revokes_token_for_all_sessions( assert not Token.objects.filter(user=staff_user).exists() me_url = reverse("api-v1:custom_auth:ffadminuser-me") assert other_client.get(me_url).status_code == status.HTTP_401_UNAUTHORIZED + + +def test_reset_password_confirm__token_auth__revokes_token( + staff_user: FFAdminUser, +) -> None: + # Given + token = Token.objects.create(user=staff_user) + authenticated_client = APIClient() + authenticated_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}") + url = reverse("api-v1:custom_auth:ffadminuser-reset-password-confirm") + + # When + response = APIClient().post( + url, + data={ + "uid": utils.encode_uid(staff_user.pk), + "token": default_token_generator.make_token(staff_user), + "new_password": "new-password-123!", + "re_new_password": "new-password-123!", + }, + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Token.objects.filter(user=staff_user).exists() + me_url = reverse("api-v1:custom_auth:ffadminuser-me") + assert authenticated_client.get(me_url).status_code == status.HTTP_401_UNAUTHORIZED diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index c65e62ab780c..2e582984e278 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -3,6 +3,7 @@ import pytest from common.projects.permissions import VIEW_PROJECT from django.db.utils import IntegrityError +from rest_framework.authtoken.models import Token from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission @@ -260,3 +261,16 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped # Given / When # Then assert FFAdminUser(email="test@example.com").email_domain == "example.com" + + +def test_set_password__existing_user_with_token__deletes_token( + staff_user: FFAdminUser, +) -> None: + # Given + Token.objects.create(user=staff_user) + + # When + staff_user.set_password("new-password-123!") # type: ignore[no-untyped-call] + + # Then + assert not Token.objects.filter(user=staff_user).exists() diff --git a/api/users/models.py b/api/users/models.py index 5af1d830178d..4904c88132b2 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -21,6 +21,7 @@ WhenFieldHasChanged, ) from pydantic import BaseModel +from rest_framework.authtoken.models import Token from integrations.pylon.identity_verification import get_user_email_signature from organisations.models import ( @@ -188,6 +189,7 @@ def set_password(self, raw_password): # type: ignore[no-untyped-def] super().set_password(raw_password) if self.id: self.password_reset_requests.all().delete() + Token.objects.filter(user=self).delete() @property def auth_type(self): # type: ignore[no-untyped-def] From 8f87f5f06bb76130e8f9c0ca4d45fd4f1fd040ed Mon Sep 17 00:00:00 2001 From: BakirFS Date: Mon, 12 Oct 2026 11:58:11 +0200 Subject: [PATCH 04/11] added warning message --- .../web/components/pages/AccountSettingsPage.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/pages/AccountSettingsPage.tsx b/frontend/web/components/pages/AccountSettingsPage.tsx index e0dfe152ed51..63b876680c15 100644 --- a/frontend/web/components/pages/AccountSettingsPage.tsx +++ b/frontend/web/components/pages/AccountSettingsPage.tsx @@ -143,8 +143,18 @@ const AccountSettingsPage: FC = () => { log you out on all devices - , including this one. You will need to log in again with your new - password. + , including this one. +
+
+ It will also{' '} + + revoke your API token + {' '} + in the API Keys tab, so anything using it will stop working. +
+
+ Log in again with your new password, then copy your new token from the + API Keys tab.
), onYes: () => { From 04d96796d113cbf56ac1a9706e2a2cd2ccaabdf5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:09:13 +0000 Subject: [PATCH 05/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/custom_auth/test_unit_custom_auth_views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index 0377f3420ecd..64353db775af 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -330,6 +330,8 @@ def test_reset_password_confirm__token_auth__revokes_token( assert not Token.objects.filter(user=staff_user).exists() me_url = reverse("api-v1:custom_auth:ffadminuser-me") assert authenticated_client.get(me_url).status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.parametrize( "signup_data, expected_onboarding_data", [ From b2d2d93be543588599958c2b665fdc45de01724c Mon Sep 17 00:00:00 2001 From: BakirFS Date: Wed, 28 Oct 2026 10:47:28 +0100 Subject: [PATCH 06/11] fixed CR comments --- api/custom_auth/views.py | 5 ++ .../test_unit_custom_auth_views.py | 54 +++++++++++++++++++ .../components/pages/AccountSettingsPage.tsx | 1 + 3 files changed, 60 insertions(+) diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 7352ab32d1e0..61e80f5b32da 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -4,6 +4,7 @@ from django.conf import settings from django.contrib.auth import user_logged_out +from django.db import transaction from django.utils import timezone from django.utils.decorators import method_decorator from djoser.views import TokenCreateView, UserViewSet # type: ignore[import-untyped] @@ -111,6 +112,10 @@ def delete_token(request): # type: ignore[no-untyped-def] name="destroy", decorator=extend_schema(parameters=[CustomUserDelete]), ) + + +@method_decorator(name="set_password", decorator=transaction.atomic) +@method_decorator(name="reset_password_confirm", decorator=transaction.atomic) class FFAdminUserViewSet(UserViewSet): # type: ignore[misc] throttle_scope = "signup" diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index 64353db775af..e9531cfea3f0 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -5,6 +5,7 @@ import pytest from django.contrib.auth.hashers import make_password from django.contrib.auth.tokens import default_token_generator +from django.db import DatabaseError from django.urls import reverse from djoser import utils # type: ignore[import-untyped] from freezegun import freeze_time @@ -332,6 +333,59 @@ def test_reset_password_confirm__token_auth__revokes_token( assert authenticated_client.get(me_url).status_code == status.HTTP_401_UNAUTHORIZED +def test_set_password__save_fails__keeps_token( + staff_user: FFAdminUser, + mocker: MagicMock, +) -> None: + # Given + staff_user.password = make_password("old-password") + staff_user.save() + token = Token.objects.create(user=staff_user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}") + url = reverse("api-v1:custom_auth:ffadminuser-set-password") + mocker.patch.object(FFAdminUser, "save", side_effect=DatabaseError) + + # When + with pytest.raises(DatabaseError): + client.post( + url, + data={ + "current_password": "old-password", + "new_password": "new-password-123!", + "re_new_password": "new-password-123!", + }, + ) + + # Then + assert Token.objects.filter(key=token.key).exists() + + +def test_reset_password_confirm__save_fails__keeps_token( + staff_user: FFAdminUser, + mocker: MagicMock, +) -> None: + # Given + token = Token.objects.create(user=staff_user) + url = reverse("api-v1:custom_auth:ffadminuser-reset-password-confirm") + mocker.patch.object(FFAdminUser, "save", side_effect=DatabaseError) + + # When + with pytest.raises(DatabaseError): + APIClient().post( + url, + data={ + "uid": utils.encode_uid(staff_user.pk), + "token": default_token_generator.make_token(staff_user), + "new_password": "new-password-123!", + "re_new_password": "new-password-123!", + }, + ) + + # Then + assert Token.objects.filter(key=token.key).exists() + + @pytest.mark.parametrize( "signup_data, expected_onboarding_data", [ diff --git a/frontend/web/components/pages/AccountSettingsPage.tsx b/frontend/web/components/pages/AccountSettingsPage.tsx index 63b876680c15..26f764ac58d5 100644 --- a/frontend/web/components/pages/AccountSettingsPage.tsx +++ b/frontend/web/components/pages/AccountSettingsPage.tsx @@ -159,6 +159,7 @@ const AccountSettingsPage: FC = () => { ), onYes: () => { setIsSaving(true) + setPasswordError(null) _data .post(`${Project.api}auth/users/set_password/`, { current_password: currentPassword, From a33f2436a8ce09ca929ed2c1e94577feeabcf25c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:48:27 +0000 Subject: [PATCH 07/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/custom_auth/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 61e80f5b32da..755d924f944c 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -112,8 +112,6 @@ def delete_token(request): # type: ignore[no-untyped-def] name="destroy", decorator=extend_schema(parameters=[CustomUserDelete]), ) - - @method_decorator(name="set_password", decorator=transaction.atomic) @method_decorator(name="reset_password_confirm", decorator=transaction.atomic) class FFAdminUserViewSet(UserViewSet): # type: ignore[misc] From e420854a4c2110b7416dda3271336a21c9c3955e Mon Sep 17 00:00:00 2001 From: BakirFS Date: Fri, 6 Nov 2026 10:42:49 +0100 Subject: [PATCH 08/11] addressed PR comments --- api/ENTERPRISE_VERSION | 0 api/custom_auth/jwt_cookie/services.py | 17 ++++ api/custom_auth/views.py | 25 ++++- .../test_unit_jwt_cookie_services.py | 42 +++++++++ .../test_unit_custom_auth_views.py | 94 ++++++++++++++++++- .../unit/users/test_unit_users_models.py | 14 --- api/users/models.py | 2 - 7 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 api/ENTERPRISE_VERSION create mode 100644 api/tests/unit/custom_auth/jwt_cookie/test_unit_jwt_cookie_services.py diff --git a/api/ENTERPRISE_VERSION b/api/ENTERPRISE_VERSION new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/jwt_cookie/services.py b/api/custom_auth/jwt_cookie/services.py index a8e308cb7b58..3718bc5a5b7c 100644 --- a/api/custom_auth/jwt_cookie/services.py +++ b/api/custom_auth/jwt_cookie/services.py @@ -1,5 +1,10 @@ from django.conf import settings +from django.utils import timezone from rest_framework.response import Response +from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, +) from rest_framework_simplejwt.tokens import SlidingToken from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY @@ -16,3 +21,15 @@ def authorise_response(user: FFAdminUser, response: Response) -> Response: samesite=settings.COOKIE_SAME_SITE, # type: ignore[arg-type] ) return response + + +def blacklist_user_tokens(user: FFAdminUser) -> None: + outstanding_tokens = OutstandingToken.objects.filter( + user=user, + expires_at__gt=timezone.now(), + blacklistedtoken__isnull=True, + ) + BlacklistedToken.objects.bulk_create( + [BlacklistedToken(token=token) for token in outstanding_tokens], + ignore_conflicts=True, + ) diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 755d924f944c..e38d1e9c4d22 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -4,7 +4,6 @@ from django.conf import settings from django.contrib.auth import user_logged_out -from django.db import transaction from django.utils import timezone from django.utils.decorators import method_decorator from djoser.views import TokenCreateView, UserViewSet # type: ignore[import-untyped] @@ -18,7 +17,7 @@ from rest_framework.status import HTTP_204_NO_CONTENT from rest_framework.throttling import ScopedRateThrottle -from custom_auth.jwt_cookie.services import authorise_response +from custom_auth.jwt_cookie.services import authorise_response, blacklist_user_tokens from custom_auth.mfa.backends.application import CustomApplicationBackend from custom_auth.mfa.trench.command.authenticate_second_factor import ( authenticate_second_step_command, @@ -112,8 +111,6 @@ def delete_token(request): # type: ignore[no-untyped-def] name="destroy", decorator=extend_schema(parameters=[CustomUserDelete]), ) -@method_decorator(name="set_password", decorator=transaction.atomic) -@method_decorator(name="reset_password_confirm", decorator=transaction.atomic) class FFAdminUserViewSet(UserViewSet): # type: ignore[misc] throttle_scope = "signup" @@ -196,3 +193,23 @@ def reset_password(self, request: Request, *args: Any, **kwargs: Any) -> Respons UserPasswordResetRequest.objects.create(user=user) return Response(status=status.HTTP_204_NO_CONTENT) + + @action(["post"], detail=False) + def set_password(self, request: Request, *args: Any, **kwargs: Any) -> Response: + user = request.user + assert isinstance(user, FFAdminUser) + response: Response = super().set_password(request, *args, **kwargs) + blacklist_user_tokens(user) + return response + + @action(["post"], detail=False) + def reset_password_confirm( + self, request: Request, *args: Any, **kwargs: Any + ) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.user + response: Response = super().reset_password_confirm(request, *args, **kwargs) + Token.objects.filter(user=user).delete() + blacklist_user_tokens(user) + return response diff --git a/api/tests/unit/custom_auth/jwt_cookie/test_unit_jwt_cookie_services.py b/api/tests/unit/custom_auth/jwt_cookie/test_unit_jwt_cookie_services.py new file mode 100644 index 000000000000..8de8a9cf2683 --- /dev/null +++ b/api/tests/unit/custom_auth/jwt_cookie/test_unit_jwt_cookie_services.py @@ -0,0 +1,42 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone +from freezegun import freeze_time +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken +from rest_framework_simplejwt.tokens import SlidingToken + +from custom_auth.jwt_cookie.services import blacklist_user_tokens +from users.models import FFAdminUser + + +def test_blacklist_user_tokens__outstanding_tokens__blacklists_only_user_tokens( + staff_user: FFAdminUser, + admin_user: FFAdminUser, +) -> None: + # Given + SlidingToken.for_user(staff_user) + SlidingToken.for_user(staff_user) + SlidingToken.for_user(admin_user) + + # When + blacklist_user_tokens(staff_user) + + # Then + assert BlacklistedToken.objects.filter(token__user=staff_user).count() == 2 + assert not BlacklistedToken.objects.filter(token__user=admin_user).exists() + + +def test_blacklist_user_tokens__expired_token__skips_token( + staff_user: FFAdminUser, +) -> None: + # Given + SlidingToken.for_user(staff_user) + token_lifetime = settings.SIMPLE_JWT["SLIDING_TOKEN_LIFETIME"] + + # When + with freeze_time(timezone.now() + token_lifetime + timedelta(seconds=1)): + blacklist_user_tokens(staff_user) + + # Then + assert not BlacklistedToken.objects.exists() diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index e9531cfea3f0..c245316612c4 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from django.contrib.auth.hashers import make_password +from django.contrib.auth.hashers import PBKDF2PasswordHasher, make_password from django.contrib.auth.tokens import default_token_generator from django.db import DatabaseError from django.urls import reverse @@ -13,7 +13,9 @@ from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import SlidingToken +from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY from users.models import FFAdminUser, HubspotTracker @@ -333,6 +335,69 @@ def test_reset_password_confirm__token_auth__revokes_token( assert authenticated_client.get(me_url).status_code == status.HTTP_401_UNAUTHORIZED +def test_set_password__jwt_cookie_auth__revokes_all_sessions( + staff_user: FFAdminUser, +) -> None: + # Given + staff_user.password = make_password("old-password") + staff_user.save() + current_client = APIClient() + current_client.cookies[JWT_SLIDING_COOKIE_KEY] = str( + SlidingToken.for_user(staff_user) + ) + other_client = APIClient() + other_client.cookies[JWT_SLIDING_COOKIE_KEY] = str( + SlidingToken.for_user(staff_user) + ) + url = reverse("api-v1:custom_auth:ffadminuser-set-password") + + # When + response = current_client.post( + url, + data={ + "current_password": "old-password", + "new_password": "new-password-123!", + "re_new_password": "new-password-123!", + }, + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + projects_url = reverse("api-v1:projects:project-list") + assert current_client.get(projects_url).status_code == status.HTTP_401_UNAUTHORIZED + assert other_client.get(projects_url).status_code == status.HTTP_401_UNAUTHORIZED + + +def test_reset_password_confirm__jwt_cookie_auth__revokes_all_sessions( + staff_user: FFAdminUser, +) -> None: + # Given + authenticated_client = APIClient() + authenticated_client.cookies[JWT_SLIDING_COOKIE_KEY] = str( + SlidingToken.for_user(staff_user) + ) + url = reverse("api-v1:custom_auth:ffadminuser-reset-password-confirm") + + # When + response = APIClient().post( + url, + data={ + "uid": utils.encode_uid(staff_user.pk), + "token": default_token_generator.make_token(staff_user), + "new_password": "new-password-123!", + "re_new_password": "new-password-123!", + }, + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + projects_url = reverse("api-v1:projects:project-list") + assert ( + authenticated_client.get(projects_url).status_code + == status.HTTP_401_UNAUTHORIZED + ) + + def test_set_password__save_fails__keeps_token( staff_user: FFAdminUser, mocker: MagicMock, @@ -386,6 +451,33 @@ def test_reset_password_confirm__save_fails__keeps_token( assert Token.objects.filter(key=token.key).exists() +def test_login__outdated_password_hash__keeps_token( + staff_user: FFAdminUser, + settings: SettingsWrapper, +) -> None: + # Given + settings.PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + ] + hasher = PBKDF2PasswordHasher() + staff_user.password = hasher.encode("password", hasher.salt(), iterations=1) + staff_user.save() + token = Token.objects.create(user=staff_user) + url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") + + # When + response = APIClient().post( + url, data={"email": staff_user.email, "password": "password"} + ) + + # Then + assert response.status_code == status.HTTP_200_OK + staff_user.refresh_from_db() + assert staff_user.password.startswith("md5$") + assert Token.objects.filter(key=token.key).exists() + + @pytest.mark.parametrize( "signup_data, expected_onboarding_data", [ diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 2e582984e278..c65e62ab780c 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -3,7 +3,6 @@ import pytest from common.projects.permissions import VIEW_PROJECT from django.db.utils import IntegrityError -from rest_framework.authtoken.models import Token from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission @@ -261,16 +260,3 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped # Given / When # Then assert FFAdminUser(email="test@example.com").email_domain == "example.com" - - -def test_set_password__existing_user_with_token__deletes_token( - staff_user: FFAdminUser, -) -> None: - # Given - Token.objects.create(user=staff_user) - - # When - staff_user.set_password("new-password-123!") # type: ignore[no-untyped-call] - - # Then - assert not Token.objects.filter(user=staff_user).exists() diff --git a/api/users/models.py b/api/users/models.py index 4904c88132b2..5af1d830178d 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -21,7 +21,6 @@ WhenFieldHasChanged, ) from pydantic import BaseModel -from rest_framework.authtoken.models import Token from integrations.pylon.identity_verification import get_user_email_signature from organisations.models import ( @@ -189,7 +188,6 @@ def set_password(self, raw_password): # type: ignore[no-untyped-def] super().set_password(raw_password) if self.id: self.password_reset_requests.all().delete() - Token.objects.filter(user=self).delete() @property def auth_type(self): # type: ignore[no-untyped-def] From 5d80f2bb9b92f8cee212b05a7df14cfd9231331e Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 6 Nov 2026 10:38:49 +0000 Subject: [PATCH 09/11] chore: Update documentation artefacts --- openapi.yaml | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index c2f765241d65..9cfb376b3811 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9628,34 +9628,6 @@ paths: - Master API Key: [] tags: - Other - /api/v1/onboarding/request/send/: - post: - operationId: api_v1_onboarding_request_send_create - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SelfHostedOnboardingSupportSendRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/SelfHostedOnboardingSupportSendRequest' - multipart/form-data: - schema: - $ref: '#/components/schemas/SelfHostedOnboardingSupportSendRequest' - responses: - '204': - description: No response body - '400': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - security: - - tokenAuth: [] - - Master API Key: [] - tags: - - Onboarding /api/v1/organisations/: get: operationId: list_organizations @@ -28624,11 +28596,6 @@ components: - ALL - ANY - NONE - SelfHostedOnboardingSupportSendRequest: - type: object - properties: - hubspotutk: - type: string SendEmailReset: type: object properties: From 9a378641128161982b369aa9ecaf5958fd59a29c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:39:03 +0000 Subject: [PATCH 10/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/custom_auth/test_unit_custom_auth_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index d002960155fa..4af6cf0cd73c 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -14,8 +14,8 @@ from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.test import APIClient -from rest_framework_simplejwt.tokens import SlidingToken from rest_framework.throttling import ScopedRateThrottle +from rest_framework_simplejwt.tokens import SlidingToken from custom_auth.jwt_cookie.constants import JWT_SLIDING_COOKIE_KEY from users.models import FFAdminUser, HubspotTracker From c79aa682e88342c3595ab414da815edfc2ba146f Mon Sep 17 00:00:00 2001 From: BakirFS Date: Fri, 6 Nov 2026 13:34:55 +0100 Subject: [PATCH 11/11] remove accidentally committed ENTERPRISE_VERSION --- api/ENTERPRISE_VERSION | 0 openapi.yaml | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) delete mode 100644 api/ENTERPRISE_VERSION diff --git a/api/ENTERPRISE_VERSION b/api/ENTERPRISE_VERSION deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/openapi.yaml b/openapi.yaml index 9cfb376b3811..c2f765241d65 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9628,6 +9628,34 @@ paths: - Master API Key: [] tags: - Other + /api/v1/onboarding/request/send/: + post: + operationId: api_v1_onboarding_request_send_create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SelfHostedOnboardingSupportSendRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SelfHostedOnboardingSupportSendRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/SelfHostedOnboardingSupportSendRequest' + responses: + '204': + description: No response body + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Onboarding /api/v1/organisations/: get: operationId: list_organizations @@ -28596,6 +28624,11 @@ components: - ALL - ANY - NONE + SelfHostedOnboardingSupportSendRequest: + type: object + properties: + hubspotutk: + type: string SendEmailReset: type: object properties: