From b53e4dee6e89ce7384a2b671085e506e951b2245 Mon Sep 17 00:00:00 2001 From: Krzysztof SOCHA Date: Sat, 28 Sep 2024 22:20:08 +0200 Subject: [PATCH 1/4] Add two_factor_validate callback to Auth that allows to perform custom validation of the second factor authentication token --- py4web/utils/auth.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index 1e0c62a9..1bddd223 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,15 @@ 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 form = Form( [ @@ -1703,9 +1707,10 @@ def two_factor(self): requires=IS_EQUAL_TO( code, error_message=self.auth.param.messages["errors"]["two_factor"], - ), + ) if self.auth.param.two_factor_validate is None else None, ), ], + validation=self.auth.param.two_factor_validate, formstyle=self.auth.param.formstyle, form_name="auth_2fa", keep_values=True, @@ -1715,7 +1720,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) From f2c2bb492bcd08417cea66acdb69b7e0e0c58f65 Mon Sep 17 00:00:00 2001 From: Krzysztof SOCHA Date: Sat, 28 Sep 2024 23:20:57 +0200 Subject: [PATCH 2/4] Add documentation related to two_factor_validate method --- docs/chapter-13.rst | 63 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/chapter-13.rst b/docs/chapter-13.rst index 67bee9da..a9d8ca55 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,46 @@ 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 number 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 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 one argument ``form``, which is 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 with an optional fallback to using the code returned by the ``two_factor_send`` method: + +.. code:: python + + def validate_code(form): + # get the user id + user_id = auth.session.get("auth.2fa_user") + + try: + # get the correct code from an external function + correct_code = generate_time_based_code(user_id) + except Exception as e: + # otherwise use the code that was returned by two_factor_send method + correct_code = auth.session.get("auth.2fa_code") + + # compare the value entered in the auth form with the correct code + if form.vars['authentication_code'] != correct_code: + form.errors['authentication_code'] = "Incorrect code." + +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, but if it is used in conjunction with ``two_factor_send`` method, it also needs to be able to validate the user input against the code that was sent to +the user. + +.. code:: python + + auth.param.two_factor_validate = validate_code + two_factor_tries ^^^^^^^^^^^^^^^^ @@ -236,9 +277,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. From 6c664a0dfbce15384c13227d2faa2a724dce57da Mon Sep 17 00:00:00 2001 From: Krzysztof SOCHA Date: Sun, 29 Sep 2024 11:54:52 +0200 Subject: [PATCH 3/4] Cleanup the way two factor validation is done to hide the internal plumbing --- py4web/utils/auth.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index 1bddd223..e8b501eb 100644 --- a/py4web/utils/auth.py +++ b/py4web/utils/auth.py @@ -1698,19 +1698,27 @@ def two_factor(self): 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"], - ) if self.auth.param.two_factor_validate is None else None, ), ], - validation=self.auth.param.two_factor_validate, + validation=two_factor_validate, formstyle=self.auth.param.formstyle, form_name="auth_2fa", keep_values=True, From 5f1ea30f88b177b551aedc9601ceec0e511acebb Mon Sep 17 00:00:00 2001 From: Krzysztof SOCHA Date: Sun, 29 Sep 2024 11:55:17 +0200 Subject: [PATCH 4/4] Correct the documentation following additional changes in the code --- docs/chapter-13.rst | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/chapter-13.rst b/docs/chapter-13.rst index a9d8ca55..2322f359 100644 --- a/docs/chapter-13.rst +++ b/docs/chapter-13.rst @@ -226,38 +226,45 @@ Also notice this method can override the code and return a new one. two_factor_validate ^^^^^^^^^^^^^^^^^^^ -By default, py4web will validate the user input in the two factor form by comparing the number entered by the user with the code generated and sent using +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 when showing the form (using for instance +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 one argument ``form``, which is the two factor authentication -form. The primary use-case for this method is validation of time-based passwords. +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 with an optional fallback to using the code returned by the ``two_factor_send`` method: +This example shows how to validate a time-based two factor code: .. code:: python - def validate_code(form): - # get the user id - user_id = auth.session.get("auth.2fa_user") - + 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: - # otherwise use the code that was returned by two_factor_send method - correct_code = auth.session.get("auth.2fa_code") + # 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 form.vars['authentication_code'] != correct_code: - form.errors['authentication_code'] = "Incorrect 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, but if it is used in conjunction with ``two_factor_send`` method, it also needs to be able to validate the user input against the code that was sent to -the user. +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