From 14f4e614bcf2ac20e93a6c8bd0d740c6bcc04e75 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 13 Aug 2025 19:45:15 +0330 Subject: [PATCH 1/9] feat(auth): Revoke refresh token on password change --- rest_framework_simplejwt/serializers.py | 47 +++++++++++++++------- tests/test_serializers.py | 52 ++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index 7fa717c67..457a1c39e 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -10,6 +10,7 @@ from .models import TokenUser from .settings import api_settings from .tokens import RefreshToken, SlidingToken, Token, UntypedToken +from .utils import get_md5_hash_password AuthUser = TypeVar("AuthUser", AbstractBaseUser, TokenUser) @@ -111,19 +112,6 @@ class TokenRefreshSerializer(serializers.Serializer): def validate(self, attrs: dict[str, Any]) -> dict[str, str]: refresh = self.token_class(attrs["refresh"]) - user_id = refresh.payload.get(api_settings.USER_ID_CLAIM, None) - if user_id: - user = ( - get_user_model() - .objects.filter(**{api_settings.USER_ID_FIELD: user_id}) - .first() - ) - if not user or not api_settings.USER_AUTHENTICATION_RULE(user): - raise AuthenticationFailed( - self.error_messages["no_active_account"], - "no_active_account", - ) - data = {"access": str(refresh.access_token)} if api_settings.ROTATE_REFRESH_TOKENS: @@ -143,6 +131,39 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: data["refresh"] = str(refresh) + # We handle user-related validation in a single, efficient block. + user_id = refresh.payload.get(api_settings.USER_ID_CLAIM, None) + if user_id: + try: + user = get_user_model().objects.get(**{api_settings.USER_ID_FIELD: user_id}) + except get_user_model().DoesNotExist: + # This handles the case where the user has been deleted. + raise AuthenticationFailed( + self.error_messages["no_active_account"], "no_active_account" + ) + + if not api_settings.USER_AUTHENTICATION_RULE(user): + raise AuthenticationFailed( + self.error_messages["no_active_account"], "no_active_account" + ) + + if api_settings.CHECK_REVOKE_TOKEN: + token_hash = refresh.payload.get(api_settings.REVOKE_TOKEN_CLAIM) + user_hash = get_md5_hash_password(user.password) + + if token_hash != user_hash: + # If the password has changed, we blacklist the token + # to prevent any further use. + if "rest_framework_simplejwt.token_blacklist" in settings.INSTALLED_APPS: + try: + refresh.blacklist() + except AttributeError: + pass + + raise AuthenticationFailed( + _("The user's password has been changed."), code="password_changed" + ) + return data diff --git a/tests/test_serializers.py b/tests/test_serializers.py index b3cdf4c72..8c12ee9e4 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -285,10 +285,11 @@ def test_it_should_raise_error_for_deleted_users(self): s = TokenRefreshSerializer(data={"refresh": str(refresh)}) + # It should raise AuthenticationFailed instead of ObjectDoesNotExist with self.assertRaises(drf_exceptions.AuthenticationFailed) as e: s.is_valid() - self.assertIn("No active account", str(e.exception)) + self.assertEqual(e.exception.get_codes(), "no_active_account") def test_it_should_raise_error_for_inactive_users(self): refresh = RefreshToken.for_user(self.user) @@ -482,6 +483,55 @@ def test_blacklist_app_not_installed_should_pass(self): reload(tokens) reload(serializers) + @override_api_settings( + CHECK_REVOKE_TOKEN=True, + REVOKE_TOKEN_CLAIM="hash_password", + BLACKLIST_AFTER_ROTATION=False, + ) + def test_refresh_token_should_fail_after_password_change(self): + """ + Tests that token refresh fails if CHECK_REVOKE_TOKEN is True and the + user's password has changed. + """ + refresh = RefreshToken.for_user(self.user) + self.user.set_password("new_password") + self.user.save() + + s = TokenRefreshSerializer(data={"refresh": str(refresh)}) + + with self.assertRaises(drf_exceptions.AuthenticationFailed) as e: + s.is_valid(raise_exception=True) + + self.assertEqual(e.exception.get_codes(), "password_changed") + + @override_api_settings( + CHECK_REVOKE_TOKEN=True, + REVOKE_TOKEN_CLAIM="hash_password", + BLACKLIST_AFTER_ROTATION=True, + ) + def test_refresh_token_should_blacklist_after_password_change(self): + """ + Tests that if token refresh fails due to a password change, the + offending refresh token is blacklisted. + """ + from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, + ) + + refresh = RefreshToken.for_user(self.user) + self.user.set_password("new_password") + self.user.save() + + s = TokenRefreshSerializer(data={"refresh": str(refresh)}) + with self.assertRaises(drf_exceptions.AuthenticationFailed): + s.is_valid(raise_exception=True) + + # Check that the token is now in the blacklist + jti = refresh[api_settings.JTI_CLAIM] + self.assertTrue(OutstandingToken.objects.filter(jti=jti).exists()) + self.assertTrue(BlacklistedToken.objects.filter(token__jti=jti).exists()) + class TestTokenVerifySerializer(TestCase): def test_it_should_raise_token_error_if_token_invalid(self): From 71f6d7d9bd22fb04f6f50bad76381066fe13c010 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:16:34 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rest_framework_simplejwt/serializers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index 457a1c39e..2c7715272 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -135,7 +135,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: user_id = refresh.payload.get(api_settings.USER_ID_CLAIM, None) if user_id: try: - user = get_user_model().objects.get(**{api_settings.USER_ID_FIELD: user_id}) + user = get_user_model().objects.get( + **{api_settings.USER_ID_FIELD: user_id} + ) except get_user_model().DoesNotExist: # This handles the case where the user has been deleted. raise AuthenticationFailed( @@ -154,14 +156,18 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: if token_hash != user_hash: # If the password has changed, we blacklist the token # to prevent any further use. - if "rest_framework_simplejwt.token_blacklist" in settings.INSTALLED_APPS: + if ( + "rest_framework_simplejwt.token_blacklist" + in settings.INSTALLED_APPS + ): try: refresh.blacklist() except AttributeError: pass raise AuthenticationFailed( - _("The user's password has been changed."), code="password_changed" + _("The user's password has been changed."), + code="password_changed", ) return data From c6708d164b506fa7fe0cff93d8b27e225e69ecf4 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 14 Aug 2025 16:00:53 +0330 Subject: [PATCH 3/9] refactor(serializers): Correct validation order in TokenRefreshSerializer --- rest_framework_simplejwt/serializers.py | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index 2c7715272..89a491d5c 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -114,23 +114,6 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: data = {"access": str(refresh.access_token)} - if api_settings.ROTATE_REFRESH_TOKENS: - if api_settings.BLACKLIST_AFTER_ROTATION: - try: - # Attempt to blacklist the given refresh token - refresh.blacklist() - except AttributeError: - # If blacklist app not installed, `blacklist` method will - # not be present - pass - - refresh.set_jti() - refresh.set_exp() - refresh.set_iat() - refresh.outstand() - - data["refresh"] = str(refresh) - # We handle user-related validation in a single, efficient block. user_id = refresh.payload.get(api_settings.USER_ID_CLAIM, None) if user_id: @@ -170,6 +153,23 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: code="password_changed", ) + if api_settings.ROTATE_REFRESH_TOKENS: + if api_settings.BLACKLIST_AFTER_ROTATION: + try: + # Attempt to blacklist the given refresh token + refresh.blacklist() + except AttributeError: + # If blacklist app not installed, `blacklist` method will + # not be present + pass + + refresh.set_jti() + refresh.set_exp() + refresh.set_iat() + refresh.outstand() + + data["refresh"] = str(refresh) + return data From 6e484a7bf05ce269149288584849cad41050a02e Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 14 Aug 2025 17:04:22 +0330 Subject: [PATCH 4/9] refactor: centralize password changed error messages in error dictionaries --- rest_framework_simplejwt/authentication.py | 6 +++++- rest_framework_simplejwt/serializers.py | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index 060ddcc33..90ae2b13c 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -33,6 +33,10 @@ class JWTAuthentication(authentication.BaseAuthentication): www_authenticate_realm = "api" media_type = "application/json" + default_error_messages = { + "password_changed": _("The user's password has been changed."), + } + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.user_model = get_user_model() @@ -143,7 +147,7 @@ def get_user(self, validated_token: Token) -> AuthUser: api_settings.REVOKE_TOKEN_CLAIM ) != get_md5_hash_password(user.password): raise AuthenticationFailed( - _("The user's password has been changed."), code="password_changed" + self.default_error_messages["password_changed"], code="password_changed" ) return user diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index 89a491d5c..38ff5a42a 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -106,7 +106,8 @@ class TokenRefreshSerializer(serializers.Serializer): token_class = RefreshToken default_error_messages = { - "no_active_account": _("No active account found for the given token.") + "no_active_account": _("No active account found for the given token."), + "password_changed": _("The user's password has been changed."), } def validate(self, attrs: dict[str, Any]) -> dict[str, str]: @@ -149,7 +150,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: pass raise AuthenticationFailed( - _("The user's password has been changed."), + self.error_messages["password_changed"], code="password_changed", ) From c1fd2ea5e7e071ff72e5a432fb93298d4979acb7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:34:43 +0000 Subject: [PATCH 5/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rest_framework_simplejwt/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework_simplejwt/authentication.py b/rest_framework_simplejwt/authentication.py index 90ae2b13c..706196600 100644 --- a/rest_framework_simplejwt/authentication.py +++ b/rest_framework_simplejwt/authentication.py @@ -147,7 +147,8 @@ def get_user(self, validated_token: Token) -> AuthUser: api_settings.REVOKE_TOKEN_CLAIM ) != get_md5_hash_password(user.password): raise AuthenticationFailed( - self.default_error_messages["password_changed"], code="password_changed" + self.default_error_messages["password_changed"], + code="password_changed", ) return user From 670baffa95763db47d5e5cb00da61657aaf64f10 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 14 Aug 2025 22:53:52 +0330 Subject: [PATCH 6/9] feat(serializers): Add full user validation to sliding token refresh Implements the same user validation logic (active status, password change) in to ensure consistent behavior with the standard . --- rest_framework_simplejwt/serializers.py | 46 +++++++++++++- tests/test_serializers.py | 82 +++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index 38ff5a42a..5bcfadf73 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -113,9 +113,6 @@ class TokenRefreshSerializer(serializers.Serializer): def validate(self, attrs: dict[str, Any]) -> dict[str, str]: refresh = self.token_class(attrs["refresh"]) - data = {"access": str(refresh.access_token)} - - # We handle user-related validation in a single, efficient block. user_id = refresh.payload.get(api_settings.USER_ID_CLAIM, None) if user_id: try: @@ -154,6 +151,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: code="password_changed", ) + data = {"access": str(refresh.access_token)} if api_settings.ROTATE_REFRESH_TOKENS: if api_settings.BLACKLIST_AFTER_ROTATION: try: @@ -178,8 +176,50 @@ class TokenRefreshSlidingSerializer(serializers.Serializer): token = serializers.CharField() token_class = SlidingToken + default_error_messages = { + "no_active_account": _("No active account found for the given token."), + "password_changed": _("The user's password has been changed."), + } + def validate(self, attrs: dict[str, Any]) -> dict[str, str]: token = self.token_class(attrs["token"]) + user_id = token.payload.get(api_settings.USER_ID_CLAIM, None) + if user_id: + try: + user = get_user_model().objects.get( + **{api_settings.USER_ID_FIELD: user_id} + ) + except get_user_model().DoesNotExist: + # This handles the case where the user has been deleted. + raise AuthenticationFailed( + self.error_messages["no_active_account"], "no_active_account" + ) + + if not api_settings.USER_AUTHENTICATION_RULE(user): + raise AuthenticationFailed( + self.error_messages["no_active_account"], "no_active_account" + ) + + if api_settings.CHECK_REVOKE_TOKEN: + token_hash = token.payload.get(api_settings.REVOKE_TOKEN_CLAIM) + user_hash = get_md5_hash_password(user.password) + + if token_hash != user_hash: + # If the password has changed, we blacklist the token + # to prevent any further use. + if ( + "rest_framework_simplejwt.token_blacklist" + in settings.INSTALLED_APPS + ): + try: + token.blacklist() + except AttributeError: + pass + + raise AuthenticationFailed( + self.error_messages["password_changed"], + code="password_changed", + ) # Check that the timestamp in the "refresh_exp" claim has not # passed diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 8c12ee9e4..ed8645e54 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -187,6 +187,15 @@ def test_it_should_produce_a_json_web_token_when_valid(self): class TestTokenRefreshSlidingSerializer(TestCase): + def setUp(self): + self.username = "test_user" + self.password = "test_password" + + self.user = User.objects.create_user( + username=self.username, + password=self.password, + ) + def test_it_should_not_validate_if_token_invalid(self): token = SlidingToken() del token["exp"] @@ -268,6 +277,74 @@ def test_it_should_update_token_exp_claim_if_everything_ok(self): self.assertTrue(old_exp < new_exp) + def test_it_should_raise_error_for_deleted_users(self): + token = SlidingToken.for_user(self.user) + self.user.delete() + + s = TokenRefreshSlidingSerializer(data={"token": str(token)}) + + # It should raise AuthenticationFailed instead of ObjectDoesNotExist + with self.assertRaises(drf_exceptions.AuthenticationFailed) as e: + s.is_valid() + + self.assertEqual(e.exception.get_codes(), "no_active_account") + + def test_it_should_raise_error_for_inactive_users(self): + token = SlidingToken.for_user(self.user) + self.user.is_active = False + self.user.save() + + s = TokenRefreshSlidingSerializer(data={"token": str(token)}) + + with self.assertRaises(drf_exceptions.AuthenticationFailed) as e: + s.is_valid() + + self.assertEqual(e.exception.get_codes(), "no_active_account") + + @override_api_settings( + CHECK_REVOKE_TOKEN=True, + REVOKE_TOKEN_CLAIM="hash_password", + BLACKLIST_AFTER_ROTATION=False, + ) + def test_sliding_token_should_fail_after_password_change(self): + """ + Tests that sliding token refresh fails if CHECK_REVOKE_TOKEN is True and the + user's password has changed. + """ + token = SlidingToken.for_user(self.user) + self.user.set_password("new_password") + self.user.save() + + s = TokenRefreshSlidingSerializer(data={"token": str(token)}) + + with self.assertRaises(drf_exceptions.AuthenticationFailed) as e: + s.is_valid(raise_exception=True) + + self.assertEqual(e.exception.get_codes(), "password_changed") + + @override_api_settings( + CHECK_REVOKE_TOKEN=True, + REVOKE_TOKEN_CLAIM="hash_password", + BLACKLIST_AFTER_ROTATION=True, + ) + def test_sliding_token_should_blacklist_after_password_change(self): + """ + Tests that if sliding token refresh fails due to a password change, the + offending token is blacklisted. + """ + token = SlidingToken.for_user(self.user) + self.user.set_password("new_password") + self.user.save() + + s = TokenRefreshSlidingSerializer(data={"token": str(token)}) + with self.assertRaises(drf_exceptions.AuthenticationFailed): + s.is_valid(raise_exception=True) + + # Check that the token is now in the blacklist + jti = token[api_settings.JTI_CLAIM] + self.assertTrue(OutstandingToken.objects.filter(jti=jti).exists()) + self.assertTrue(BlacklistedToken.objects.filter(token__jti=jti).exists()) + class TestTokenRefreshSerializer(TestCase): def setUp(self): @@ -514,11 +591,6 @@ def test_refresh_token_should_blacklist_after_password_change(self): Tests that if token refresh fails due to a password change, the offending refresh token is blacklisted. """ - from rest_framework_simplejwt.token_blacklist.models import ( - BlacklistedToken, - OutstandingToken, - ) - refresh = RefreshToken.for_user(self.user) self.user.set_password("new_password") self.user.save() From 7d2d28a9d173db6e61615850179c9c2d83f58265 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Fri, 15 Aug 2025 19:51:53 +0330 Subject: [PATCH 7/9] refactor: Inline password hash comparison in serializers Simplifies the conditional check by removing temporary variables for the token hash and user password hash. --- rest_framework_simplejwt/serializers.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index 5bcfadf73..f9c6072ee 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -131,10 +131,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: ) if api_settings.CHECK_REVOKE_TOKEN: - token_hash = refresh.payload.get(api_settings.REVOKE_TOKEN_CLAIM) - user_hash = get_md5_hash_password(user.password) - - if token_hash != user_hash: + if refresh.payload.get(api_settings.REVOKE_TOKEN_CLAIM) != get_md5_hash_password(user.password): # If the password has changed, we blacklist the token # to prevent any further use. if ( @@ -201,10 +198,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: ) if api_settings.CHECK_REVOKE_TOKEN: - token_hash = token.payload.get(api_settings.REVOKE_TOKEN_CLAIM) - user_hash = get_md5_hash_password(user.password) - - if token_hash != user_hash: + if token.payload.get(api_settings.REVOKE_TOKEN_CLAIM) != get_md5_hash_password(user.password): # If the password has changed, we blacklist the token # to prevent any further use. if ( From b6ed5859b3b42f389b86c61b2df9c60205bef158 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 Aug 2025 16:22:09 +0000 Subject: [PATCH 8/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rest_framework_simplejwt/serializers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rest_framework_simplejwt/serializers.py b/rest_framework_simplejwt/serializers.py index f9c6072ee..04c8be43b 100644 --- a/rest_framework_simplejwt/serializers.py +++ b/rest_framework_simplejwt/serializers.py @@ -131,7 +131,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: ) if api_settings.CHECK_REVOKE_TOKEN: - if refresh.payload.get(api_settings.REVOKE_TOKEN_CLAIM) != get_md5_hash_password(user.password): + if refresh.payload.get( + api_settings.REVOKE_TOKEN_CLAIM + ) != get_md5_hash_password(user.password): # If the password has changed, we blacklist the token # to prevent any further use. if ( @@ -198,7 +200,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]: ) if api_settings.CHECK_REVOKE_TOKEN: - if token.payload.get(api_settings.REVOKE_TOKEN_CLAIM) != get_md5_hash_password(user.password): + if token.payload.get( + api_settings.REVOKE_TOKEN_CLAIM + ) != get_md5_hash_password(user.password): # If the password has changed, we blacklist the token # to prevent any further use. if ( From ea764f171f1acb091b336e08eca86d51c11214a6 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 16 Aug 2025 20:28:19 +0330 Subject: [PATCH 9/9] BREAKING: return 401 AuthenticationFailed instead of 404 DoesNotExist for missing users --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1d8618a..8839c4889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [Unreleased] + +### Changed +- **BREAKING:** In `serializers.py`, when a user linked to a token is missing or deleted, the code now raises `AuthenticationFailed("no_active_account")` instead of allowing `DoesNotExist` to propagate. + - Response changed from **404 Not Found** → **401 Unauthorized**. + - Improves security by not leaking whether a user/token exists. + - Follows RFC 7235, where authentication failures should return 401. + - Clearer for clients: signals an auth issue instead of suggesting the endpoint is missing. + + ## 5.5.1 Missing Migration for rest_framework_simplejwt.token_blacklist app. A previously missing migration (0013_blacklist) has now been added. This issue arose because the migration file was mistakenly not generated earlier. This migration was never part of an official release, but users following the latest master branch may have encountered it.