Skip to content

Commit 529f8bb

Browse files
mdefechemdefeche
andauthored
Add hook mechanism when a login attempt fails or succeeds (#904)
* Add hook mechanism when a login attempt fails or succeeds * Add doc on ON_LOGIN_SUCCESS and ON_LOGIN_FAILED --------- Co-authored-by: mdefeche <[email protected]>
1 parent da5d4f2 commit 529f8bb

File tree

4 files changed

+55
-3
lines changed

4 files changed

+55
-3
lines changed

docs/settings.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Some of Simple JWT's behavior can be customized through settings variables in
3434
"USER_ID_FIELD": "id",
3535
"USER_ID_CLAIM": "user_id",
3636
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
37+
"ON_LOGIN_SUCCESS": "rest_framework_simplejwt.serializers.default_on_login_success",
38+
"ON_LOGIN_FAILED": "rest_framework_simplejwt.serializers.default_on_login_failed",
3739
3840
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
3941
"TOKEN_TYPE_CLAIM": "token_type",
@@ -223,6 +225,20 @@ to the callable as an argument. The default rule is to check that the ``is_activ
223225
flag is still ``True``. The callable must return a boolean, ``True`` if authorized,
224226
``False`` otherwise resulting in a 401 status code.
225227

228+
``ON_LOGIN_SUCCESS``
229+
----------------------------
230+
231+
Callable to add logic whenever a login attempt succeeded. ``UPDATE_LAST_LOGIN``
232+
must be set to ``True``. The callable does not return anything.
233+
The default callable updates last_login field in the auth_user table upon login
234+
(TokenObtainPairView).
235+
236+
``ON_LOGIN_FAILED``
237+
----------------------------
238+
239+
Callable to add logic whenever a login attempt failed. The callable does not
240+
return anything. The default callable does nothing (``pass``)
241+
226242
``AUTH_TOKEN_CLASSES``
227243
----------------------
228244

rest_framework_simplejwt/serializers.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from typing import Any, Optional, TypeVar
22

33
from django.conf import settings
4-
from django.contrib.auth import authenticate, get_user_model
4+
from django.contrib.auth import _clean_credentials, authenticate, get_user_model
55
from django.contrib.auth.models import AbstractBaseUser, update_last_login
66
from django.utils.translation import gettext_lazy as _
77
from rest_framework import exceptions, serializers
88
from rest_framework.exceptions import AuthenticationFailed, ValidationError
9+
from rest_framework.request import Request
910

1011
from .models import TokenUser
1112
from .settings import api_settings
@@ -55,6 +56,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[Any, Any]:
5556
self.user = authenticate(**authenticate_kwargs)
5657

5758
if not api_settings.USER_AUTHENTICATION_RULE(self.user):
59+
api_settings.ON_LOGIN_FAILED(
60+
_clean_credentials(attrs), self.context.get("request")
61+
)
5862
raise exceptions.AuthenticationFailed(
5963
self.error_messages["no_active_account"],
6064
"no_active_account",
@@ -79,7 +83,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]:
7983
data["access"] = str(refresh.access_token)
8084

8185
if api_settings.UPDATE_LAST_LOGIN:
82-
update_last_login(None, self.user)
86+
api_settings.ON_LOGIN_SUCCESS(self.user, self.context.get("request"))
8387

8488
return data
8589

@@ -95,7 +99,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, str]:
9599
data["token"] = str(token)
96100

97101
if api_settings.UPDATE_LAST_LOGIN:
98-
update_last_login(None, self.user)
102+
api_settings.ON_LOGIN_SUCCESS(self.user, self.context.get("request"))
99103

100104
return data
101105

@@ -258,3 +262,11 @@ def validate(self, attrs: dict[str, Any]) -> dict[Any, Any]:
258262
except AttributeError:
259263
pass
260264
return {}
265+
266+
267+
def default_on_login_success(user: AuthUser, request: Optional[Request]) -> None:
268+
update_last_login(None, user)
269+
270+
271+
def default_on_login_failed(credentials: dict, request: Optional[Request]) -> None:
272+
pass

rest_framework_simplejwt/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
"USER_ID_FIELD": "id",
3030
"USER_ID_CLAIM": "user_id",
3131
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
32+
"ON_LOGIN_SUCCESS": "rest_framework_simplejwt.serializers.default_on_login_success",
33+
"ON_LOGIN_FAILED": "rest_framework_simplejwt.serializers.default_on_login_failed",
3234
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
3335
"TOKEN_TYPE_CLAIM": "token_type",
3436
"JTI_CLAIM": "jti",
@@ -52,6 +54,8 @@
5254
"JSON_ENCODER",
5355
"TOKEN_USER_CLASS",
5456
"USER_AUTHENTICATION_RULE",
57+
"ON_LOGIN_SUCCESS",
58+
"ON_LOGIN_FAILED",
5559
)
5660

5761
REMOVED_SETTINGS = (

tests/test_views.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import timedelta
2+
from unittest import mock
23
from unittest.mock import patch
34

45
from django.contrib.auth import get_user_model
@@ -105,6 +106,25 @@ def test_update_last_login_updated(self):
105106
self.assertIsNotNone(user.last_login)
106107
self.assertGreaterEqual(timezone.now(), user.last_login)
107108

109+
def test_on_login_failed_is_called(self):
110+
# Patch the ON_LOGIN_FAILED setting
111+
with mock.patch(
112+
"rest_framework_simplejwt.settings.api_settings.ON_LOGIN_FAILED"
113+
) as mocked_hook:
114+
self.test_credentials_wrong()
115+
mocked_hook.assert_called_once()
116+
117+
# Optional: check exact arguments
118+
args, kwargs = mocked_hook.call_args
119+
credentials, request = args
120+
self.assertEqual(
121+
credentials,
122+
{
123+
User.USERNAME_FIELD: self.username,
124+
"password": "********************",
125+
},
126+
)
127+
108128

109129
class TestTokenRefreshView(APIViewTestCase):
110130
view_name = "token_refresh"

0 commit comments

Comments
 (0)