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: