Skip to content

Commit

Permalink
Auth TOTP fix (#925)
Browse files Browse the repository at this point in the history
* Add two_factor_validate callback to Auth that allows to perform custom validation of the second factor authentication token

* Add documentation related to two_factor_validate method

* Cleanup the way two factor validation is done to hide the internal plumbing

* Correct the documentation following additional changes in the code
  • Loading branch information
kszys committed Sep 29, 2024
1 parent e123754 commit 997107b
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 17 deletions.
70 changes: 60 additions & 10 deletions docs/chapter-13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,18 @@ username and password, and two factor authentication is enabled for the user, th

There are a few Auth settings available to control how two factor authentication works.

The follow can be specified on Auth instantiation:
The following can be specified on Auth instantiation:

- two_factor_required
- two_factor_send
- ``two_factor_required``
- ``two_factor_send``
- ``two_factor_validate``

two_factor_required
^^^^^^^^^^^^^^^^^^^

When you pass a method name to the ``two_factor_required`` parameter you are telling py4web to call that method to determine whether or not this login should
be use or bypass two factor authentication. If your method returns True, then this login requires two factor. If it returns False,
two factor authentication is bypassed for this login.
be use or bypass two factor authentication. If your method returns True, then this login requires two factor. If it returns False, two factor authentication
is bypassed for this login.

Sample ``two_factor_required`` method

Expand All @@ -195,8 +196,8 @@ This example shows how to allow users that are on a specific network.
two_factor_send
^^^^^^^^^^^^^^^

When two factor authentication is active, py4web generates a 6 digit code (using random.randint) and sends it to you. How this code is sent, is up to you.
The two_factor_send argument to the Auth class allows you to specify the method that sends the two factor code to the user.
When two factor authentication is active, py4web can generate a 6 digit code (using random.randint) and makes it possible to send it to the user. How this code is
sent, is up to you. The ``two_factor_send`` argument to the Auth class allows you to specify the method that sends the two factor code to the user.

This example shows how to send an email with the two factor code:

Expand All @@ -214,14 +215,61 @@ This example shows how to send an email with the two factor code:
print(e)
return code
Notice that this method takes to arguments: the current user, and the code to be sent.
Notice that this method takes two arguments: the current user, and the code to be sent.
Also notice this method can override the code and return a new one.

.. code:: python
auth.param.two_factor_required = user_outside_network
auth.param.two_factor_send = send_two_factor_email
two_factor_validate
^^^^^^^^^^^^^^^^^^^

By default, py4web will validate the user input in the two factor form by comparing the code entered by the user with the code generated and sent using
``two_factor_send``. However, sometimes it may be useful to define a custom validation of this user-entered code. For instance, if one would like to use the
TOTP (or the Time-Based One-Time-Passwords) as the two factor authentication method, the validation requires comparing the code entered by the user with the
value generated at the same time at the server side. Hence, it is not sufficient to generate that value earlier when showing the form (using for instance
``two_factor_send`` method), because by the time the user submits the form, the current valid value may already be different. Instead, this value should be
generated when validating the form submitted by the user.

To accomplish such custom validation, the ``two_factor_validate`` method is available. It takes two arguments - the current user and the code that was entered
by the user into the two factor authentication form. The primary use-case for this method is validation of time-based passwords.

This example shows how to validate a time-based two factor code:

.. code:: python
def validate_code(user, code):
try:
# get the correct code from an external function
correct_code = generate_time_based_code(user_id)
except Exception as e:
# return None to indicate that validation could not be performed
return None
# compare the value entered in the auth form with the correct code
if code == correct_code:
return True
else:
return False
The ``validate_code`` method must return one of three values:

- ``True`` - if the validation succeded,
- ``False`` - if the validation failed,
- ``None`` - if the validation was not possible for any reason

Notice that - if defined - this method is _always_ called to validate the two factor authentication form. It is up to you to decide what kind of validation it
does. If the returned value is ``True``, the user input will be accepted as valid. If the returned value is ``False`` then the user input will be rejected as
invalid, number of tries will be decresed by one, and user will be asked to try again. If the returned value is ``None`` the user input will be checked against
the code generated with the use of ``two_factor_send`` method and the final result will depend on that comparison. In this case authentication will fail if ``two_factor_send``
method was not defined, and hence no code was sent to the user.

.. code:: python
auth.param.two_factor_validate = validate_code
two_factor_tries
^^^^^^^^^^^^^^^^

Expand All @@ -236,9 +284,11 @@ Once this is all setup, the flow for two factor authentication is:
- present the login page
- upon successful login and user passes two_factor_required
- redirect to py4web auth/two_factor endpoint
- generate 6 digit verification code
- call two_factor_send to send the verification code to the user
- if ``two_factor_send`` method has been defined:
- generate 6 digit verification code
- call ``two_factor_send`` to send the verification code to the user
- display verification page where user can enter their code
- if ``two_factor_validate`` method has been defined - call it to validate the user-entered code
- upon successful verification, take user to _next_url that was passed to the login page

Important! If you filtered ``ALLOWED_ACTIONS`` in your app, make sure to whitelist the "two_factor" action so not to block the two factor API.
Expand Down
27 changes: 20 additions & 7 deletions py4web/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ def __init__(
password_in_db=True,
two_factor_required=None,
two_factor_send=None,
two_factor_validate=None,
template_args=None,
):
# configuration parameters
Expand All @@ -255,6 +256,7 @@ def __init__(
expose_all_models=True,
two_factor_required=two_factor_required,
two_factor_send=two_factor_send,
two_factor_validate=two_factor_validate,
two_factor_tries=3,
auth_enforcer=None,
template_args=template_args or {},
Expand Down Expand Up @@ -1675,7 +1677,7 @@ def _reset_two_factor(self):
self.auth.session["auth.2fa_tries_left"] = self.auth.param.two_factor_tries

def two_factor(self):
if self.auth.param.two_factor_send is None:
if (self.auth.param.two_factor_send is None) and (self.auth.param.two_factor_validate is None):
raise HTTP(404)

user_id = self.auth.session.get("auth.2fa_user")
Expand All @@ -1686,26 +1688,37 @@ def two_factor(self):

user = self.auth.db.auth_user(user_id)
code = self.auth.session.get("auth.2fa_code")
if not code:
if (not code) and (not self.auth.param.two_factor_send is None):
# generate and send the code
code = str(random.randint(100000, 999999))
code = self.auth.param.two_factor_send(user, code)
# store code in session
self.auth.session["auth.2fa_code"] = code
self.auth.session["auth.2fa_tries_left"] = self.auth.param.two_factor_tries
elif self.auth.session.get("auth.2fa_tries_left") is None:
self.auth.session["auth.2fa_tries_left"] = self.auth.param.two_factor_tries

def two_factor_validate(form):
# external validation outcome
outcome = None
if self.auth.param.two_factor_validate:
outcome = self.auth.param.two_factor_validate(user, form.vars['authentication_code'])
# outcome:
# True: external validation passed
# False: external validation failed
# None: external validation status unknown - check against the generated code
if outcome==False or ((outcome is None) and (form.vars['authentication_code']!=code)):
form.errors['authentication_code'] = self.auth.param.messages["errors"]["two_factor"]

form = Form(
[
Field(
"authentication_code",
label=self.auth.param.messages["labels"]["two_factor"],
required=True,
requires=IS_EQUAL_TO(
code,
error_message=self.auth.param.messages["errors"]["two_factor"],
),
),
],
validation=two_factor_validate,
formstyle=self.auth.param.formstyle,
form_name="auth_2fa",
keep_values=True,
Expand All @@ -1715,7 +1728,7 @@ def two_factor(self):
if form.accepted:
# reset the 2f session
self._reset_two_factor()
# store user i session
# store user in session
self.auth.store_user_in_session(user["id"])
# login user
self._postprocessing("login", form, user)
Expand Down

0 comments on commit 997107b

Please sign in to comment.