Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ Flask-Security Changelog

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

Version 5.8.0
-------------

Released TBD

Features & Improvements
+++++++++++++++++++++++
- (:pr:`xx`) Add API :py:meth:`.UserMixin.check_tf_required` to allow applications to control which users
require two-factor authentication.

Fixes
+++++

Docs and Chores
+++++++++++++++
- (:pr:`1150`) Update de_DE translations (swaeberle)
- (:pr:`1151`) Update ca_ES translations (arielvb)
- (:pr:`1152`) Update es_ES translations (arielvb)

Version 5.7.1
-------------

Expand Down
14 changes: 9 additions & 5 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1255,16 +1255,20 @@ Configuration related to the two-factor authentication feature.
.. py:data:: SECURITY_TWO_FACTOR

Specifies if Flask-Security should enable the two-factor login feature.
If set to ``True``, in addition to their passwords, users will be required to
enter a code that is sent to them. Note that unless
:data:`SECURITY_TWO_FACTOR_REQUIRED` is set - this is opt-in.

Default: ``False``.
.. py:data:: SECURITY_TWO_FACTOR_REQUIRED

If set to ``True`` then all users will be required to setup and use two-factor authorization.
If set to ``True`` then all users will be required to setup and use two-factor authentication.
Please see :py:meth:`.UserMixin.check_tf_required` and :ref:`two_factor_configurations:Fine-Grained Control of Two-Factor`
for ways the application can
more finely tune which users require two-factor authentication.

Default: ``False``.

.. versionchanged:: 5.8.0
Added overridable method that can alter this behavior.

.. py:data:: SECURITY_TWO_FACTOR_ENABLED_METHODS

Specifies the default enabled methods for two-factor authentication.
Expand Down Expand Up @@ -1341,7 +1345,7 @@ Configuration related to the two-factor authentication feature.
.. py:data:: SECURITY_TWO_FACTOR_SELECT_URL

Specifies the two-factor select URL. This is used when the user has
setup more than one second factor.
setup more than one second factor - see :ref:`webauthn:webauthn`.

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

Expand Down
2 changes: 1 addition & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ paths:
The user successfully signed in using their primary credential.
Note that depending on SECURITY_TWO_FACTOR configuration variable, a second form of authentication might be required prior to the user being fully authenticated.
`tf_required` will be set to True in this case.
Note that if 2FA is not configured, none of the ``tf_`` properties will be returned.
Note that if 2FA is not configured, only the ``tf_required`` property (=False) will be returned.
- $ref: "#/components/schemas/LoginJsonResponse"
text/html:
schema:
Expand Down
12 changes: 10 additions & 2 deletions docs/two_factor_configurations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Two-factor Configurations
Two-factor authentication provides a second layer of security to any type of
login, requiring extra information or a secondary device to log in, in addition
to ones login credentials. The added feature includes the ability to add a
secondary authentication method using either via email, sms message, or an
secondary authentication method using either an email link, sms message, or an
Authenticator app such as Google, Lastpass, or Authy.

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

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

Normal Login
~~~~~~~~~~~~
Expand Down Expand Up @@ -201,3 +201,11 @@ security of a two factor authentication but with a slightly better user experien
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
``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
: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``.

Fine-Grained Control of Two-Factor
+++++++++++++++++++++++++++++++++++
The decision whether to require a second factor after primary authentication is made in :py:meth:`.UserMixin.check_tf_required`.
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
and recent two-factor authentication isn't 'valid' (see above).

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.
44 changes: 40 additions & 4 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
:copyright: (c) 2012 by Matt Wright.
:copyright: (c) 2017 by CERN.
:copyright: (c) 2017 by ETH Zurich, Swiss Data Science Center.
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
:copyright: (c) 2019-2026 by J. Christopher Wagner (jwag).
:license: MIT, see LICENSE for more details.
"""

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

def get_security_payload(self) -> dict[str, t.Any]:
"""Serialize user object as response payload.
Override this to return any/all of the user object in JSON responses.
Override this to return any/all the user object in JSON responses.
Return a dict.
"""
return {}
Expand All @@ -1016,8 +1016,8 @@ def get_redirect_qparams(
) -> dict[str, t.Any]:
"""Return user info that will be added to redirect query params.

:param existing: A dict that will be updated.
:return: A dict whose keys will be query params and values will be query values.
:param existing: Existing dict of params to update.
:return: A dict whose keys are query params and values are query values.

The returned dict will always have an 'identity' key/value.
If the User Model contains 'email', an 'email' key/value will be added.
Expand Down Expand Up @@ -1104,6 +1104,42 @@ def tf_send_security_token(self, method: str, **kwargs: t.Any) -> str | None:
return get_message("FAILED_TO_SEND_CODE")[0]
return None

def check_tf_required(
self, tf_setup_methods: list[tuple[str, str]], tf_fresh: bool
) -> tuple[bool, list[tuple[str, str]]]:
"""Check if current user requires two-factor authentication.

:param tf_setup_methods: A tuple of (two_factor method, label) - methods
the user has already set up (from all two-factor implementations)
:param tf_fresh: if True then user has recently completed
two-factor authentication on the requesting device
:return: Whether TFA is required for this user and a possibly augmented
list of allowable methods

The default implementation uses global configuration values.
An application could for example require two-factor authentication for users
with a particular role, or not require two-factor for 'new' users.
This is called AFTER the user has successfully authenticated.

.. versionadded:: 5.8.0
"""
if cv("TWO_FACTOR_REQUIRED") or len(tf_setup_methods) > 0:
if cv("TWO_FACTOR_ALWAYS_VALIDATE") or not tf_fresh:
return True, tf_setup_methods
return False, tf_setup_methods

def check_tf_required_setup(self) -> bool:
"""Check if current user requires two-factor authentication.
This is called as part of two-factor setup to inform the caller

N.B. this is only called from tf-setup - not from webauthn and
is only used to improve UX - the above method check_tf_required is the
definitive answer in the authentication path.

.. versionadded:: 5.8.0
"""
return cv("TWO_FACTOR_REQUIRED")


class WebAuthnMixin:
if t.TYPE_CHECKING: # pragma: no cover
Expand Down
6 changes: 4 additions & 2 deletions flask_security/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,19 +969,21 @@ class TwoFactorSetupForm(Form):
phone = TelField(get_form_field_label("phone"))
submit = SubmitField(get_form_field_label("submit"))

def __init__(self, *args, **kwargs):
def __init__(self, *args: t.Any, **kwargs: t.Any):
super().__init__(*args, **kwargs)
self.user: UserMixin | None = None # set by view

def validate(self, **kwargs: t.Any) -> bool:
if not super().validate(**kwargs): # pragma: no cover
return False
assert self.user is not None
choices = list(cv("TWO_FACTOR_ENABLED_METHODS"))
assert isinstance(self.setup.errors, list)
assert isinstance(self.phone.errors, list)
if "email" in choices:
# backwards compat
choices.append("mail")
if not cv("TWO_FACTOR_REQUIRED"):
if not self.user.check_tf_required_setup():
choices.append("disable")
if "setup" not in self.data or self.data["setup"] not in choices:
self.setup.errors.append(get_message("TWO_FACTOR_METHOD_NOT_AVAILABLE")[0])
Expand Down
4 changes: 2 additions & 2 deletions flask_security/templates/security/two_factor_setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
This template receives different input based on state of tf-setup. In addition
to form values the following are available:
On GET or unsuccessful POST:
choices: Value of SECURITY_TWO_FACTOR_ENABLED_METHODS (with possible addition of 'delete')
two_factor_required: Value of SECURITY_TWO_FACTOR_REQUIRED
choices: Value of SECURITY_TWO_FACTOR_ENABLED_METHODS (with possible addition of 'disable')
two_factor_required: Value of SECURITY_TWO_FACTOR_REQUIRED (or result from user.tf_check_required_setup)
primary_method: the translated name of two-factor method that has already been set up.
On successful POST:
chosen_method: which 2FA method was chosen (e.g. sms, authenticator)
Expand Down
92 changes: 47 additions & 45 deletions flask_security/tf_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Flask-Security Two-Factor Plugin Module

:copyright: (c) 2022-2024 by J. Christopher Wagner (jwag).
:copyright: (c) 2022-2026 by J. Christopher Wagner (jwag).
:license: MIT, see LICENSE for more details.

TODO:
Expand Down Expand Up @@ -180,7 +180,8 @@ def method_to_impl(self, user: UserMixin, method: str) -> TfPluginBase | None:

def get_setup_tf_methods(self, user: UserMixin) -> list[tuple[str, str]]:
"""Return a list of tuples representing currently configured methods.
The tuple is (value, label) - suitable for use in a FlaskForm Select element.
The tuple is (value, translated(value)) - suitable for use in a
FlaskForm Select element.
"""
methods = []
for impl in self._tf_impls.values():
Expand All @@ -202,49 +203,50 @@ def tf_enter(
"""
json_payload: dict[str, t.Any]
if _security.support_mfa:
tf_setup_methods = [k for k, v in self.get_setup_tf_methods(user)]
if cv("TWO_FACTOR_REQUIRED") or len(tf_setup_methods) > 0:
tf_fresh = tf_verify_validity_token(user.fs_uniquifier)
if cv("TWO_FACTOR_ALWAYS_VALIDATE") or not tf_fresh:
# Clean out any potential old session info - in case of previous
# aborted 2FA attempt.
tf_clean_session()

json_payload = {"tf_required": True}
if remember_me:
session["tf_remember_login"] = remember_me

session["tf_user_id"] = user.fs_uniquifier
# A backwards compat hack - the original twofactor could be setup
# as part of initial login.
if len(tf_setup_methods) == 0:
# only initial two-factor implementation supports this
return self._tf_impls["code"].tf_login(
user, json_payload, next_loc
)
elif len(tf_setup_methods) == 1:
# method_to_impl can't return None here since we just
# got the methods up above.
impl = t.cast(
TfPluginBase,
self.method_to_impl(user, tf_setup_methods[0]),
)
return impl.tf_login(user, json_payload, next_loc)
else:
session["tf_select"] = True
if not _security._want_json(request):
values = dict(next=next_loc) if next_loc else dict()
return redirect(url_for_security("tf_select", **values))
# Let's force app to go through tf-select just in case we want
# to do further validation... However, provide the choices
# so they can just do a POST
json_payload.update(
{
"tf_select": True,
"tf_setup_methods": tf_setup_methods,
}
)
return simple_render_json(json_payload)
tf_setup_methods = self.get_setup_tf_methods(user)
tf_fresh = tf_verify_validity_token(user.fs_uniquifier)
tf_required, tf_available_methods = user.check_tf_required(
tf_setup_methods, tf_fresh
)
if tf_required:
tf_setup_methods_keys = [t1 for t1, t2 in tf_setup_methods]
# Clean out any potential old session info - in case of previous
# aborted 2FA attempt.
tf_clean_session()

json_payload = {"tf_required": True}
if remember_me:
session["tf_remember_login"] = remember_me

session["tf_user_id"] = user.fs_uniquifier
# A backwards compat hack - the original twofactor could be setup
# as part of initial login.
if len(tf_setup_methods) == 0:
# only initial two-factor implementation supports this
return self._tf_impls["code"].tf_login(user, json_payload, next_loc)
elif len(tf_setup_methods) == 1:
# method_to_impl can't return None here since we just
# got the methods up above.
impl = t.cast(
TfPluginBase,
self.method_to_impl(user, tf_setup_methods_keys[0]),
)
return impl.tf_login(user, json_payload, next_loc)
else:
session["tf_select"] = True
if not _security._want_json(request):
values = dict(next=next_loc) if next_loc else dict()
return redirect(url_for_security("tf_select", **values))
# Let's force app to go through tf-select just in case we want
# to do further validation... However, provide the choices
# so they can just do a POST
json_payload.update(
{
"tf_select": True,
"tf_setup_methods": tf_setup_methods_keys,
}
)
return simple_render_json(json_payload)
return None

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