Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth TOTP fix #925

Merged
merged 4 commits into from
Sep 29, 2024
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
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
Loading