From 997107b44da7ae5e447519731366189d68da27ec Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Sun, 29 Sep 2024 19:05:56 +0200 Subject: [PATCH] Auth TOTP fix (#925) * 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 --- docs/chapter-13.rst | 70 +++++++++++++++++++++++++++++++++++++------- py4web/utils/auth.py | 27 ++++++++++++----- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/docs/chapter-13.rst b/docs/chapter-13.rst index 67bee9da..2322f359 100644 --- a/docs/chapter-13.rst +++ b/docs/chapter-13.rst @@ -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 @@ -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: @@ -214,7 +215,7 @@ 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 @@ -222,6 +223,53 @@ Also notice this method can override the code and return a new one. 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 ^^^^^^^^^^^^^^^^ @@ -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. diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index 1e0c62a9..e8b501eb 100644 --- a/py4web/utils/auth.py +++ b/py4web/utils/auth.py @@ -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 @@ -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 {}, @@ -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") @@ -1686,13 +1688,27 @@ 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( [ @@ -1700,12 +1716,9 @@ def two_factor(self): "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, @@ -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)