Skip to content

Commit 41ea63c

Browse files
authored
Add API (method in UserMixin) to enable fine-tuned two-factor requirements. (#1170)
Previously, whether two-factor authentication was required was controlled by SECURITY_TWO_FACTOR_REQUIRED and whether the user had recently successfully performed a second factor authentication. This default behavior hasn't changed - but now, all the authentication code calls UserMixin.check_tf_required() which, if overridden in an application, can make the decision any way they want - for example - based on user group. closes #1168
1 parent 334fd45 commit 41ea63c

File tree

15 files changed

+294
-105
lines changed

15 files changed

+294
-105
lines changed

CHANGES.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ Flask-Security Changelog
33

44
Here you can see the full list of changes between each Flask-Security release.
55

6+
Version 5.8.0
7+
-------------
8+
9+
Released TBD
10+
11+
Features & Improvements
12+
+++++++++++++++++++++++
13+
- (:pr:`xx`) Add API :py:meth:`.UserMixin.check_tf_required` to allow applications to control which users
14+
require two-factor authentication.
15+
16+
Fixes
17+
+++++
18+
19+
Docs and Chores
20+
+++++++++++++++
21+
- (:pr:`1150`) Update de_DE translations (swaeberle)
22+
- (:pr:`1151`) Update ca_ES translations (arielvb)
23+
- (:pr:`1152`) Update es_ES translations (arielvb)
24+
625
Version 5.7.1
726
-------------
827

docs/configuration.rst

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,16 +1255,20 @@ Configuration related to the two-factor authentication feature.
12551255
.. py:data:: SECURITY_TWO_FACTOR
12561256
12571257
Specifies if Flask-Security should enable the two-factor login feature.
1258-
If set to ``True``, in addition to their passwords, users will be required to
1259-
enter a code that is sent to them. Note that unless
1260-
:data:`SECURITY_TWO_FACTOR_REQUIRED` is set - this is opt-in.
12611258

12621259
Default: ``False``.
12631260
.. py:data:: SECURITY_TWO_FACTOR_REQUIRED
12641261
1265-
If set to ``True`` then all users will be required to setup and use two-factor authorization.
1262+
If set to ``True`` then all users will be required to setup and use two-factor authentication.
1263+
Please see :py:meth:`.UserMixin.check_tf_required` and :ref:`two_factor_configurations:Fine-Grained Control of Two-Factor`
1264+
for ways the application can
1265+
more finely tune which users require two-factor authentication.
12661266

12671267
Default: ``False``.
1268+
1269+
.. versionchanged:: 5.8.0
1270+
Added overridable method that can alter this behavior.
1271+
12681272
.. py:data:: SECURITY_TWO_FACTOR_ENABLED_METHODS
12691273
12701274
Specifies the default enabled methods for two-factor authentication.
@@ -1341,7 +1345,7 @@ Configuration related to the two-factor authentication feature.
13411345
.. py:data:: SECURITY_TWO_FACTOR_SELECT_URL
13421346
13431347
Specifies the two-factor select URL. This is used when the user has
1344-
setup more than one second factor.
1348+
setup more than one second factor - see :ref:`webauthn:webauthn`.
13451349

13461350
Default: ``"/tf-select"``.
13471351

docs/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ paths:
9494
The user successfully signed in using their primary credential.
9595
Note that depending on SECURITY_TWO_FACTOR configuration variable, a second form of authentication might be required prior to the user being fully authenticated.
9696
`tf_required` will be set to True in this case.
97-
Note that if 2FA is not configured, none of the ``tf_`` properties will be returned.
97+
Note that if 2FA is not configured, only the ``tf_required`` property (=False) will be returned.
9898
- $ref: "#/components/schemas/LoginJsonResponse"
9999
text/html:
100100
schema:

docs/two_factor_configurations.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Two-factor Configurations
44
Two-factor authentication provides a second layer of security to any type of
55
login, requiring extra information or a secondary device to log in, in addition
66
to ones login credentials. The added feature includes the ability to add a
7-
secondary authentication method using either via email, sms message, or an
7+
secondary authentication method using either an email link, sms message, or an
88
Authenticator app such as Google, Lastpass, or Authy.
99

1010
The following code sample illustrates how to get started as quickly as
@@ -160,7 +160,7 @@ The Two-factor (2FA) API has four paths:
160160
- Rescue
161161

162162
When using forms, the flow from one state to the next is handled by the forms themselves. When using JSON
163-
the application must of course explicitly access the appropriate endpoints. The descriptions below describe the JSON access pattern.
163+
the application must explicitly access the appropriate endpoints. The descriptions below is for the JSON access pattern.
164164

165165
Normal Login
166166
~~~~~~~~~~~~
@@ -201,3 +201,11 @@ security of a two factor authentication but with a slightly better user experien
201201
and clicking the 'Remember' button on the login form. Once the two factor code is validated, a cookie is set to allow skipping the validation step. The cookie is named
202202
``tf_validity`` and contains the signed token containing the user's ``fs_uniquifier``. The cookie and token are both set to expire after the time delta given in
203203
:py:data:`SECURITY_TWO_FACTOR_LOGIN_VALIDITY`. Note that setting ``SECURITY_TWO_FACTOR_LOGIN_VALIDITY`` to 0 is equivalent to ``SECURITY_TWO_FACTOR_ALWAYS_VALIDATE`` being ``True``.
204+
205+
Fine-Grained Control of Two-Factor
206+
+++++++++++++++++++++++++++++++++++
207+
The decision whether to require a second factor after primary authentication is made in :py:meth:`.UserMixin.check_tf_required`.
208+
The default implementation returns True if :py:data:`SECURITY_TWO_FACTOR_REQUIRED` is set OR the user has a two-factor method already setup AND
209+
and recent two-factor authentication isn't 'valid' (see above).
210+
211+
This method can be overridden in the applications User class. A common use case might be to require two-factor for any user with the 'admin' role.

flask_security/core.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
:copyright: (c) 2012 by Matt Wright.
88
:copyright: (c) 2017 by CERN.
99
:copyright: (c) 2017 by ETH Zurich, Swiss Data Science Center.
10-
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
10+
:copyright: (c) 2019-2026 by J. Christopher Wagner (jwag).
1111
:license: MIT, see LICENSE for more details.
1212
"""
1313

@@ -1006,7 +1006,7 @@ def has_permission(self, permission: str) -> bool:
10061006

10071007
def get_security_payload(self) -> dict[str, t.Any]:
10081008
"""Serialize user object as response payload.
1009-
Override this to return any/all of the user object in JSON responses.
1009+
Override this to return any/all the user object in JSON responses.
10101010
Return a dict.
10111011
"""
10121012
return {}
@@ -1016,8 +1016,8 @@ def get_redirect_qparams(
10161016
) -> dict[str, t.Any]:
10171017
"""Return user info that will be added to redirect query params.
10181018
1019-
:param existing: A dict that will be updated.
1020-
:return: A dict whose keys will be query params and values will be query values.
1019+
:param existing: Existing dict of params to update.
1020+
:return: A dict whose keys are query params and values are query values.
10211021
10221022
The returned dict will always have an 'identity' key/value.
10231023
If the User Model contains 'email', an 'email' key/value will be added.
@@ -1104,6 +1104,42 @@ def tf_send_security_token(self, method: str, **kwargs: t.Any) -> str | None:
11041104
return get_message("FAILED_TO_SEND_CODE")[0]
11051105
return None
11061106

1107+
def check_tf_required(
1108+
self, tf_setup_methods: list[tuple[str, str]], tf_fresh: bool
1109+
) -> tuple[bool, list[tuple[str, str]]]:
1110+
"""Check if current user requires two-factor authentication.
1111+
1112+
:param tf_setup_methods: A tuple of (two_factor method, label) - methods
1113+
the user has already set up (from all two-factor implementations)
1114+
:param tf_fresh: if True then user has recently completed
1115+
two-factor authentication on the requesting device
1116+
:return: Whether TFA is required for this user and a possibly augmented
1117+
list of allowable methods
1118+
1119+
The default implementation uses global configuration values.
1120+
An application could for example require two-factor authentication for users
1121+
with a particular role, or not require two-factor for 'new' users.
1122+
This is called AFTER the user has successfully authenticated.
1123+
1124+
.. versionadded:: 5.8.0
1125+
"""
1126+
if cv("TWO_FACTOR_REQUIRED") or len(tf_setup_methods) > 0:
1127+
if cv("TWO_FACTOR_ALWAYS_VALIDATE") or not tf_fresh:
1128+
return True, tf_setup_methods
1129+
return False, tf_setup_methods
1130+
1131+
def check_tf_required_setup(self) -> bool:
1132+
"""Check if current user requires two-factor authentication.
1133+
This is called as part of two-factor setup to inform the caller
1134+
1135+
N.B. this is only called from tf-setup - not from webauthn and
1136+
is only used to improve UX - the above method check_tf_required is the
1137+
definitive answer in the authentication path.
1138+
1139+
.. versionadded:: 5.8.0
1140+
"""
1141+
return cv("TWO_FACTOR_REQUIRED")
1142+
11071143

11081144
class WebAuthnMixin:
11091145
if t.TYPE_CHECKING: # pragma: no cover

flask_security/forms.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -969,19 +969,21 @@ class TwoFactorSetupForm(Form):
969969
phone = TelField(get_form_field_label("phone"))
970970
submit = SubmitField(get_form_field_label("submit"))
971971

972-
def __init__(self, *args, **kwargs):
972+
def __init__(self, *args: t.Any, **kwargs: t.Any):
973973
super().__init__(*args, **kwargs)
974+
self.user: UserMixin | None = None # set by view
974975

975976
def validate(self, **kwargs: t.Any) -> bool:
976977
if not super().validate(**kwargs): # pragma: no cover
977978
return False
979+
assert self.user is not None
978980
choices = list(cv("TWO_FACTOR_ENABLED_METHODS"))
979981
assert isinstance(self.setup.errors, list)
980982
assert isinstance(self.phone.errors, list)
981983
if "email" in choices:
982984
# backwards compat
983985
choices.append("mail")
984-
if not cv("TWO_FACTOR_REQUIRED"):
986+
if not self.user.check_tf_required_setup():
985987
choices.append("disable")
986988
if "setup" not in self.data or self.data["setup"] not in choices:
987989
self.setup.errors.append(get_message("TWO_FACTOR_METHOD_NOT_AVAILABLE")[0])

flask_security/templates/security/two_factor_setup.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
This template receives different input based on state of tf-setup. In addition
33
to form values the following are available:
44
On GET or unsuccessful POST:
5-
choices: Value of SECURITY_TWO_FACTOR_ENABLED_METHODS (with possible addition of 'delete')
6-
two_factor_required: Value of SECURITY_TWO_FACTOR_REQUIRED
5+
choices: Value of SECURITY_TWO_FACTOR_ENABLED_METHODS (with possible addition of 'disable')
6+
two_factor_required: Value of SECURITY_TWO_FACTOR_REQUIRED (or result from user.tf_check_required_setup)
77
primary_method: the translated name of two-factor method that has already been set up.
88
On successful POST:
99
chosen_method: which 2FA method was chosen (e.g. sms, authenticator)

flask_security/tf_plugin.py

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
Flask-Security Two-Factor Plugin Module
66
7-
:copyright: (c) 2022-2024 by J. Christopher Wagner (jwag).
7+
:copyright: (c) 2022-2026 by J. Christopher Wagner (jwag).
88
:license: MIT, see LICENSE for more details.
99
1010
TODO:
@@ -180,7 +180,8 @@ def method_to_impl(self, user: UserMixin, method: str) -> TfPluginBase | None:
180180

181181
def get_setup_tf_methods(self, user: UserMixin) -> list[tuple[str, str]]:
182182
"""Return a list of tuples representing currently configured methods.
183-
The tuple is (value, label) - suitable for use in a FlaskForm Select element.
183+
The tuple is (value, translated(value)) - suitable for use in a
184+
FlaskForm Select element.
184185
"""
185186
methods = []
186187
for impl in self._tf_impls.values():
@@ -202,49 +203,50 @@ def tf_enter(
202203
"""
203204
json_payload: dict[str, t.Any]
204205
if _security.support_mfa:
205-
tf_setup_methods = [k for k, v in self.get_setup_tf_methods(user)]
206-
if cv("TWO_FACTOR_REQUIRED") or len(tf_setup_methods) > 0:
207-
tf_fresh = tf_verify_validity_token(user.fs_uniquifier)
208-
if cv("TWO_FACTOR_ALWAYS_VALIDATE") or not tf_fresh:
209-
# Clean out any potential old session info - in case of previous
210-
# aborted 2FA attempt.
211-
tf_clean_session()
212-
213-
json_payload = {"tf_required": True}
214-
if remember_me:
215-
session["tf_remember_login"] = remember_me
216-
217-
session["tf_user_id"] = user.fs_uniquifier
218-
# A backwards compat hack - the original twofactor could be setup
219-
# as part of initial login.
220-
if len(tf_setup_methods) == 0:
221-
# only initial two-factor implementation supports this
222-
return self._tf_impls["code"].tf_login(
223-
user, json_payload, next_loc
224-
)
225-
elif len(tf_setup_methods) == 1:
226-
# method_to_impl can't return None here since we just
227-
# got the methods up above.
228-
impl = t.cast(
229-
TfPluginBase,
230-
self.method_to_impl(user, tf_setup_methods[0]),
231-
)
232-
return impl.tf_login(user, json_payload, next_loc)
233-
else:
234-
session["tf_select"] = True
235-
if not _security._want_json(request):
236-
values = dict(next=next_loc) if next_loc else dict()
237-
return redirect(url_for_security("tf_select", **values))
238-
# Let's force app to go through tf-select just in case we want
239-
# to do further validation... However, provide the choices
240-
# so they can just do a POST
241-
json_payload.update(
242-
{
243-
"tf_select": True,
244-
"tf_setup_methods": tf_setup_methods,
245-
}
246-
)
247-
return simple_render_json(json_payload)
206+
tf_setup_methods = self.get_setup_tf_methods(user)
207+
tf_fresh = tf_verify_validity_token(user.fs_uniquifier)
208+
tf_required, tf_available_methods = user.check_tf_required(
209+
tf_setup_methods, tf_fresh
210+
)
211+
if tf_required:
212+
tf_setup_methods_keys = [t1 for t1, t2 in tf_setup_methods]
213+
# Clean out any potential old session info - in case of previous
214+
# aborted 2FA attempt.
215+
tf_clean_session()
216+
217+
json_payload = {"tf_required": True}
218+
if remember_me:
219+
session["tf_remember_login"] = remember_me
220+
221+
session["tf_user_id"] = user.fs_uniquifier
222+
# A backwards compat hack - the original twofactor could be setup
223+
# as part of initial login.
224+
if len(tf_setup_methods) == 0:
225+
# only initial two-factor implementation supports this
226+
return self._tf_impls["code"].tf_login(user, json_payload, next_loc)
227+
elif len(tf_setup_methods) == 1:
228+
# method_to_impl can't return None here since we just
229+
# got the methods up above.
230+
impl = t.cast(
231+
TfPluginBase,
232+
self.method_to_impl(user, tf_setup_methods_keys[0]),
233+
)
234+
return impl.tf_login(user, json_payload, next_loc)
235+
else:
236+
session["tf_select"] = True
237+
if not _security._want_json(request):
238+
values = dict(next=next_loc) if next_loc else dict()
239+
return redirect(url_for_security("tf_select", **values))
240+
# Let's force app to go through tf-select just in case we want
241+
# to do further validation... However, provide the choices
242+
# so they can just do a POST
243+
json_payload.update(
244+
{
245+
"tf_select": True,
246+
"tf_setup_methods": tf_setup_methods_keys,
247+
}
248+
)
249+
return simple_render_json(json_payload)
248250
return None
249251

250252
def tf_complete(self, user: UserMixin, dologin: bool) -> str | None:

0 commit comments

Comments
 (0)