Skip to content

Commit 05ee541

Browse files
committed
Support for tracking failed authentication.
This adds a new signal - user_failed_authn, and a new method to UserMixin: track_failed_authn that sends that signal. All places that accept a credential and that fails call through track_failed_authn(). API errors, and missing data don't trigger the signal. Added a signals fixture for testing which tracks all signals during a test, and a test_util check_signals that can verify precisely which signals were received. closes #1188
1 parent aa69cda commit 05ee541

17 files changed

Lines changed: 250 additions & 44 deletions

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Features & Improvements
1414
require two-factor authentication.
1515
- (:issue:`1178`) Add Cache-Control headers.
1616
- (:issue:`1165`) Add support for using Social Login (OAuth) for verification.
17+
- (:issue:`1188`) Add tracking of failed authentication attempts via :py:meth:`.UserMixin.track_failed_authn`
18+
and :py:data:`user_failed_authn`
1719

1820
Fixes
1921
+++++

docs/api.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ sends the following signals.
284284
sender), it is passed `user`, and `authn_via` arguments. The `authn_via` argument
285285
specifies how the user authenticated - it will be a list with possible values
286286
of ``password``, ``sms``, ``authenticator``, ``email``, ``confirm``, ``reset``,
287-
``register``.
287+
``register``, ``webauthn``, ``oauth``.
288288

289289
.. versionadded:: 3.4.0
290290

@@ -296,6 +296,18 @@ sends the following signals.
296296

297297
.. versionadded:: 5.4.0
298298

299+
.. data:: user_failed_authn
300+
301+
Sent from :py:meth:`.UserMixin.track_failed_authn` when a user fails authentication.
302+
In addition to the app (which is the sender) it is passed:
303+
304+
* `user` - the user that failed authentication
305+
* `endpoint` - the endpoint that was accessed
306+
* `auth_type` - `password`, `code`, `passkey`
307+
* `tfa` - True if this was a two factor authentication attempt
308+
309+
.. versionadded:: 5.8.0
310+
299311
.. data:: user_registered
300312

301313
Sent when a user registers on the site. In addition to the app (which is the

flask_security/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
tf_security_token_sent,
8787
tf_disabled,
8888
user_authenticated,
89+
user_failed_authn,
8990
user_unauthenticated,
9091
user_confirmed,
9192
user_registered,

flask_security/core.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
MfRecoveryCodesForm,
7171
MfRecoveryCodesUtil,
7272
)
73+
from .signals import user_failed_authn
7374
from .tf_plugin import TfPlugin, TwoFactorSelectForm
7475
from .twofactor import tf_send_security_token
7576
from .unified_signin import (
@@ -1146,6 +1147,45 @@ def check_tf_required_setup(self) -> bool:
11461147
"""
11471148
return cv("TWO_FACTOR_REQUIRED")
11481149

1150+
def track_failed_authn(
1151+
self, endpoint: str | None, auth_type: str, tfa: bool = False
1152+
) -> None:
1153+
"""Called when a user fails to authenticate.
1154+
1155+
endpoint - blueprint endpoint name:
1156+
1157+
- security.login
1158+
- security.verify
1159+
- security.us_signin
1160+
- security.us_verify
1161+
- security.us_verify_link
1162+
- security.wan_signin_response
1163+
- security.wan_verify_response
1164+
- security.two_factor_token_validation
1165+
1166+
auth_type is what failed:
1167+
1168+
- password
1169+
- passcode (unified signin)
1170+
- passkey (webauthn
1171+
1172+
tfa - True if it was a second factor that failed
1173+
1174+
This is called any time a credential is presented but is not verifiable.
1175+
It is NOT called on API errors or missing data.
1176+
1177+
The default implementation sends the user_failed_authn signal.
1178+
1179+
"""
1180+
user_failed_authn.send(
1181+
current_app._get_current_object(), # type: ignore[attr-defined]
1182+
_async_wrapper=current_app.ensure_sync,
1183+
endpoint=endpoint,
1184+
user=self,
1185+
auth_type=auth_type,
1186+
tfa=tfa,
1187+
)
1188+
11491189

11501190
class WebAuthnMixin:
11511191
if t.TYPE_CHECKING: # pragma: no cover

flask_security/forms.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
:copyright: (c) 2012 by Matt Wright.
88
:copyright: (c) 2017 by CERN.
9-
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
9+
:copyright: (c) 2019-2026 by J. Christopher Wagner (jwag).
1010
:license: MIT, see LICENSE for more details.
1111
"""
1212

@@ -596,12 +596,14 @@ def validate(self, **kwargs: t.Any) -> bool:
596596
return False
597597
if not self.user.password:
598598
# This is result of PASSWORD_REQUIRED=False and UNIFIED_SIGNIN
599+
self.user.track_failed_authn(request.endpoint, "password")
599600
self.password.errors.append(get_message("INVALID_PASSWORD")[0])
600601
# Reduce timing variation between existing and non-existing users
601602
hash_password(self.password.data)
602603
return False
603604
self.password.data = _security.password_util.normalize(self.password.data)
604605
if not self.user.verify_and_update_password(self.password.data):
606+
self.user.track_failed_authn(request.endpoint, "password")
605607
self.password.errors.append(get_message("INVALID_PASSWORD")[0])
606608
return False
607609

@@ -661,6 +663,7 @@ def validate(self, **kwargs: t.Any) -> bool:
661663
self.password.data = _security.password_util.normalize(self.password.data)
662664
assert isinstance(self.password.errors, list)
663665
if not self.user.verify_and_update_password(self.password.data):
666+
self.user.track_failed_authn(request.endpoint, "password")
664667
self.password.errors.append(get_message("INVALID_PASSWORD")[0])
665668
return False
666669
return True
@@ -1012,6 +1015,7 @@ def __init__(self, *args: t.Any, **kwargs: t.Any):
10121015
self.primary_method: str = ""
10131016
self.tf_totp_secret: str = ""
10141017
self.user: UserMixin | None = None # set by view
1018+
self.is_setup: bool = False
10151019

10161020
def validate(self, **kwargs: t.Any) -> bool:
10171021
if not super().validate(**kwargs): # pragma: no cover
@@ -1038,6 +1042,8 @@ def validate(self, **kwargs: t.Any) -> bool:
10381042
user=self.user,
10391043
window=self.window,
10401044
):
1045+
if not self.is_setup:
1046+
self.user.track_failed_authn(request.endpoint, "code", True)
10411047
self.code.errors.append(get_message("TWO_FACTOR_INVALID_TOKEN")[0])
10421048
return False
10431049

flask_security/oauth_glue.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def oauthresponse(name: str) -> ResponseValue:
136136
if response:
137137
return response
138138
# two-factor not required - login user
139-
login_user(user)
139+
login_user(user, authn_via=["oauth"])
140140
if cv("REDIRECT_BEHAVIOR") == "spa":
141141
redirect_url = get_url(
142142
cv("POST_OAUTH_LOGIN_VIEW"), qparams=user.get_redirect_qparams()

flask_security/signals.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Flask-Security signals module
66
77
:copyright: (c) 2012 by Matt Wright.
8-
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
8+
:copyright: (c) 2019-2026 by J. Christopher Wagner (jwag).
99
:license: MIT, see LICENSE for more details.
1010
"""
1111

@@ -17,6 +17,8 @@
1717

1818
user_unauthenticated = signals.signal("user-unauthenticated")
1919

20+
user_failed_authn = signals.signal("user-failed-authn")
21+
2022
user_registered = signals.signal("user-registered")
2123

2224
# For cases of RETURN_GENERIC_RESPONSES with existing email/username

flask_security/unified_signin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def validate2(self) -> bool:
213213
ok = True
214214
break
215215
if not ok:
216+
self.user.track_failed_authn(request.endpoint, "passcode")
216217
self.passcode.errors.append(get_message("INVALID_PASSWORD_CODE")[0])
217218
return False
218219

@@ -721,6 +722,8 @@ def us_verify_link() -> ResponseValue:
721722
window=cv("US_TOKEN_VALIDITY"),
722723
):
723724
m, c = generic_message("INVALID_CODE", "GENERIC_AUTHN_FAILED")
725+
user.track_failed_authn(request.endpoint, "passcode")
726+
724727
if cv("REDIRECT_BEHAVIOR") == "spa":
725728
return redirect(
726729
get_url(

flask_security/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,7 @@ def two_factor_setup_validate(token: str) -> ResponseValue:
951951
form.tf_totp_secret = totp_secret
952952
form.primary_method = method
953953
form.user = current_user
954+
form.is_setup = True
954955

955956
if form.validate_on_submit():
956957
tf_clean_session() # until we completely remove session based setup/state
@@ -1040,6 +1041,7 @@ def two_factor_token_validation():
10401041
logout_user()
10411042
return tf_illegal_state(form, cv("TWO_FACTOR_ERROR_VIEW"))
10421043
form.user = current_user
1044+
form.is_setup = True
10431045

10441046
form.primary_method = pm
10451047
form.tf_totp_secret = totp_secret

flask_security/webauthn.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
Flask-Security WebAuthn module
66
7-
:copyright: (c) 2021-2025 by J. Christopher Wagner (jwag).
7+
:copyright: (c) 2021-2026 by J. Christopher Wagner (jwag).
88
:license: MIT, see LICENSE for more details.
99
1010
This implements support for webauthn/FIDO2 Level 2 using the py_webauthn package.
@@ -342,7 +342,7 @@ def validate(self, **kwargs: t.Any) -> bool:
342342
credential_public_key=self.cred.public_key,
343343
credential_current_sign_count=self.cred.sign_count,
344344
)
345-
# Start by verifying requiring user_verification - if that succeeds then
345+
# Start by verifying requiring user_verification - if that succeeds, then
346346
# this authn could be used for both primary and secondary.
347347
# If it fails, then try to verify with user_verification == False - unless
348348
# as part of signin the app required user_verification (as stored in the state)
@@ -358,6 +358,7 @@ def validate(self, **kwargs: t.Any) -> bool:
358358
self.credential.errors.append(
359359
get_message("WEBAUTHN_NO_VERIFY", cause=str(exc))[0]
360360
)
361+
self.user.track_failed_authn(request.endpoint, "passkey")
361362
return False
362363
return True
363364

0 commit comments

Comments
 (0)