diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cef565d8..464e336d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: check-merge-conflict - id: fix-byte-order-marker - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -23,7 +23,7 @@ repos: hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 additional_dependencies: diff --git a/CHANGES.rst b/CHANGES.rst index a48fa161..b3bdf32d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,13 +12,14 @@ Features & Improvements +++++++++++++++++++++++ - (:issue:`956`) Add support for changing registered user's email (:py:data:`SECURITY_CHANGE_EMAIL`). - (:issue:`944`) Change default password hash to argon2 (was bcrypt). See below for details. +- (:pr:`990`) Add freshness capability to auth tokens (enables /us-setup to function w/ just auth tokens). +- (:pr:`xxx`) Add support /tf-setup to not require sessions (use a state token). Fixes +++++ - (:pr:`972`) Set :py:data:`SECURITY_CSRF_COOKIE` at beginning (GET /login) of authentication ritual - just as we return the CSRF token. (thanks @e-goto) -- (:issue:`973`) login and unified sign in should handle GET for authenticated user consistently -- (:pr:`990`) Add freshness capability to auth tokens (enables /us-setup to function w/ just auth tokens) +- (:issue:`973`) login and unified sign in should handle GET for authenticated user consistently. Docs and Chores +++++++++++++++ @@ -29,8 +30,11 @@ Backwards Compatibility Concerns +++++++++++++++++++++++++++++++++ - Notes around the change to argon2 as the default password hash: - applications should add the argon2_cffi package to their requirements (it is included in the flask_security[common] extras). - - leave bcrypt installed to that old passwords still work. + - leave bcrypt installed so that old passwords still work. - the default configuration will re-hash passwords with argon2 upon first use. +- Changes to /tf-setup + The old path - using state set in the session still works as before. The new path is + just for the case an authenticated user wants to change their 2FA setup. Version 5.4.3 ------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index 9081366b..13095c05 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -668,6 +668,9 @@ Core - rarely need changing .. py:data:: SECURITY_WAN_SALT Default: ``"wan-salt"`` +.. py:data:: SECURITY_TWO_FACTOR_SETUP_SALT + + Default: ``"tf-setup-salt"`` .. py:data:: SECURITY_EMAIL_PLAINTEXT @@ -1221,6 +1224,14 @@ Configuration related to the two-factor authentication feature. Specifies the number of seconds access token is valid. Default: ``120``. +.. py:data:: SECURITY_TWO_FACTOR_SETUP_WITHIN + + Specifies the amount of time a user has before their two factor setup + token expires. Always pluralize the time unit for this value. + + Default: ``"30 minutes"`` + + .. versionadded:: 5.5.0 .. py:data:: SECURITY_TWO_FACTOR_RESCUE_MAIL Specifies the email address users send mail to when they can't complete the @@ -1952,6 +1963,7 @@ The default messages and error levels can be found in ``core.py``. * ``SECURITY_MSG_TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL`` * ``SECURITY_MSG_TWO_FACTOR_PERMISSION_DENIED`` * ``SECURITY_MSG_TWO_FACTOR_METHOD_NOT_AVAILABLE`` +* ``SECURITY_MSG_TWO_FACTOR_SETUP_EXPIRED`` * ``SECURITY_MSG_TWO_FACTOR_DISABLED`` * ``SECURITY_MSG_UNAUTHORIZED`` * ``SECURITY_MSG_UNAUTHENTICATED`` diff --git a/docs/features.rst b/docs/features.rst index 58534318..99ccc686 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -109,7 +109,8 @@ value set. .. note:: While every Flask-Security endpoint will accept an authentication token header, there are some endpoints that require session information (e.g. a session cookie). - Please read :ref:`freshness_topic` and :ref:`csrf_topic` + This includes entering in a second factor and handling of :ref:`CSRF`. + As of release 5.5.0, authentication tokens by default carry freshness information. User Registration ----------------- diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 49b2bca6..3c1b3c9e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1049,20 +1049,6 @@ paths: required: true schema: type: string - get: - summary: Validate unified sign in setup request. - description: > - This does nothing but redirect back to the setup form. - responses: - 200: - description: Get form. - content: - text/html: - schema: - type: string - description: render_template(SECURITY_US_SETUP_TEMPLATE) - example: render_template(SECURITY_US_SETUP_TEMPLATE) - post: summary: Validate passcode sent and store setup method. requestBody: @@ -1082,15 +1068,19 @@ paths: schema: $ref: "#/components/schemas/UsSetupValidateJsonResponse" 302: - description: Successfully validated and persisted sign in method. + description: Redirect based on success or failure. headers: Location: description: | On form-success: SECURITY_US_POST_SETUP_VIEW + + On form-error: .us-setup + + Form errors include bad code, expired token, bad token. schema: type: string 400: - description: Validation failed. + description: Failed - bad code, expired token, bad token. content: application/json: schema: @@ -1182,11 +1172,10 @@ paths: 3) An authenticated user wishes to enable or disable 2FA (assuming SECURITY_TWO_FACTOR_REQUIRED is False). - Allowed 2FA methods are controlled via the configuration SECURITY_TWO_FACTOR_ENABLED_METHODS. - - This endpoint is protected by a 'freshness' check - meaning the caller will be required to have authenticated recently. In addition, to ensure correctness, the newly setup method must be verified by sending and entering a code prior to it being permanently stored. + This endpoint is protected by a 'freshness' check - meaning the caller will be required to have authenticated recently. + In addition, to ensure correctness, the newly setup method must be verified by sending and entering a code prior to it being permanently stored. requestBody: required: true content: @@ -1230,6 +1219,49 @@ paths: application/json: schema: $ref: "#/components/schemas/DefaultJsonErrorResponse" + /tf-setup/{token}: + parameters: + - name: token + in: path + required: true + schema: + type: string + post: + summary: Validate code sent and store setup method. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TfSetupValidateRequest" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/TfSetupValidateRequest" + responses: + 200: + description: Successfully validated and persisted two factor method. + content: + application/json: + schema: + $ref: "#/components/schemas/TfSetupValidateJsonResponse" + 302: + description: Success or Failure. + headers: + Location: + description: | + On form-success: SECURITY_TWO_FACTOR_POST_SETUP_VIEW + + On form-error: .tf-setup + + Form errors include bad code, expired token, bad token. + schema: + type: string + 400: + description: Failed - bad code, expired token, bad token. + content: + application/json: + schema: + $ref: "#/components/schemas/DefaultJsonErrorResponse" /tf-validate: get: summary: GET current two-factor validate code form @@ -2324,7 +2356,14 @@ components: description: > Current state of Two Factor configuration. Not present when disabling 2FA. This will be set to 'validating_profile' indicating the caller needs to call '/tf-validate' with the correct code. + N.B. as of 5.5.0 this is only used for setting up as part of initial login when 2FA is required. + See tf_state_token below for use when an authenticated user wants to change their 2FA method. example: validating_profile + tf_state_token: + type: string + description: > + Timed and signed token containing necessary state to complete the setup. To validate the method POST + the code to '/tf-setup/'. tf_primary_method: type: string description: Current method being configured (deprecated). @@ -2382,7 +2421,30 @@ components: tf_signin_url: type: string description: The value of SECURITY_WAN_SIGNIN_URL - + TfSetupValidateRequest: + type: object + required: [code] + properties: + code: + type: string + description: Code as received from method being setup. + TfSetupValidateJsonResponse: + allOf: + - $ref: '#/components/schemas/BaseJsonResponse' + - type: object + properties: + response: + type: object + properties: + tf_method: + type: string + description: The method as passed into API. + tf_primary_method: + type: string + description: The method as passed into API. + tf_phone: + type: string + description: Phone number if set. WanRegister: type: object required: [ name, usage ] diff --git a/docs/patterns.rst b/docs/patterns.rst index 45d44cf5..e18707d8 100644 --- a/docs/patterns.rst +++ b/docs/patterns.rst @@ -103,7 +103,7 @@ Flask-Security itself uses this as part of securing the following endpoints: - .mf_recovery_codes ("/mf-recovery-codes") - .change_email ("/change-email") -Using the :py:data:`SECURITY_FRESHNESS` and :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` configuration variables. +using the :py:data:`SECURITY_FRESHNESS` and :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` configuration variables. .. tip:: The timestamp of the users last successful authentication is stored in the session diff --git a/docs/two_factor_configurations.rst b/docs/two_factor_configurations.rst index d7dcaa8f..f42f9de9 100644 --- a/docs/two_factor_configurations.rst +++ b/docs/two_factor_configurations.rst @@ -147,8 +147,10 @@ Theory of Operation +++++++++++++++++++++ .. note:: - The Two-factor feature requires that session cookies be received and sent as part of the API. + Confirming a code as part of user authentication requires that session cookies be received and sent as part of the API. This is true regardless of whether the application uses forms or JSON. + The ``/tf-setup`` endpoint requires freshness information which (as of 5.5.0) is available from the authentication token + (as well as the session) - so changing a user's 2FA method can be done without cookies. The Two-factor (2FA) API has four paths: @@ -172,11 +174,11 @@ Changing 2FA Setup An authenticated user can change their 2FA configuration (primary_method, phone number, etc.). In order to prevent a user from being locked out, the new configuration must be validated before it is stored permanently. The user starts with a GET on ``/tf-setup``. This will return a list of configured 2FA methods the user can choose from, and the existing configuration. This must be followed with a POST on ``/tf-setup`` with the new primary -method (and phone number if SMS). In the case of SMS, a code will be sent to the phone/device and again use ``/tf-validate`` to confirm code. +method (and phone number if SMS). In the case of SMS or email, a code will be sent. In addition, a state_token will be returned in the response to +the POST - this should be used to POST the code to ``/tf-setup/``. In the case of setting up an authenticator app, the response to the POST will contain the QRcode image as well as the required information for manual entry. -Once the code has been successfully -entered, the new configuration will be permanently stored. +Once the code has been successfully entered, the new configuration will be permanently stored. Initial login/registration ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/flask_security/__init__.py b/flask_security/__init__.py index aa923368..71df6cad 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -70,6 +70,8 @@ MfRecoveryCodesForm, ) from .signals import ( + change_email_confirmed, + change_email_instructions_sent, confirm_instructions_sent, login_instructions_sent, password_changed, diff --git a/flask_security/core.py b/flask_security/core.py index 57c6cf7c..b0481907 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -244,6 +244,8 @@ "secure": False, "samesite": "Strict", }, + "TWO_FACTOR_SETUP_SALT": "tf-setup-salt", + "TWO_FACTOR_SETUP_WITHIN": "30 minutes", "TWO_FACTOR_RESCUE_EMAIL": True, "MULTI_FACTOR_RECOVERY_CODES": False, "MULTI_FACTOR_RECOVERY_CODES_N": 5, @@ -539,6 +541,10 @@ _("You successfully disabled two factor authorization."), "success", ), + "TWO_FACTOR_SETUP_EXPIRED": ( + _("Setup must be completed within %(within)s. Please start over."), + "error", + ), "US_CURRENT_METHODS": ( _("Currently active sign in options: %(method_list)s."), "info", @@ -1296,6 +1302,7 @@ def __init__( self.change_email_serializer: URLSafeTimedSerializer self.confirm_serializer: URLSafeTimedSerializer self.us_setup_serializer: URLSafeTimedSerializer + self.tf_setup_serializer: URLSafeTimedSerializer self.tf_validity_serializer: URLSafeTimedSerializer self.wan_serializer: URLSafeTimedSerializer self.principal: Principal @@ -1477,6 +1484,7 @@ def init_app( self.change_email_serializer = _get_serializer(app, "change_email") self.confirm_serializer = _get_serializer(app, "confirm") self.us_setup_serializer = _get_serializer(app, "us_setup") + self.tf_setup_serializer = _get_serializer(app, "two_factor_setup") self.tf_validity_serializer = _get_serializer(app, "two_factor_validity") self.wan_serializer = _get_serializer(app, "wan") self.principal = _get_principal(app) diff --git a/flask_security/templates/security/two_factor_setup.html b/flask_security/templates/security/two_factor_setup.html index 8388e74d..52103a55 100644 --- a/flask_security/templates/security/two_factor_setup.html +++ b/flask_security/templates/security/two_factor_setup.html @@ -8,6 +8,9 @@ On successful POST: chosen_method: which 2FA method was chosen (e.g. sms, authenticator) choices: Value of SECURITY_TWO_FACTOR_ENABLED_METHODS + changing: boolean - True if user is trying to change/disable 2FA + state_token: if changing - this is the new (non-session) way to validate + the new 2FA method If chosen_method == 'authenticator': authr_qrcode: the image source for the qrcode @@ -60,7 +63,12 @@

{{ _fsdomain("In addition to your username and password, you'll need to use {# This is the fill in code part #}
{{ _fsdomain("Enter code to complete setup") }}
-
+ {% if changing %} + {% set faction = url_for_security('two_factor_setup_validate', token=state_token) %} + {% else %} + {% set faction = url_for_security('two_factor_token_validation') %} + {% endif %} + {{ two_factor_verify_code_form.hidden_tag() }} {{ render_field_with_errors(two_factor_verify_code_form.code, placeholder=_fsdomain("enter numeric code")) }}
{{ render_field(two_factor_verify_code_form.submit) }}
diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index 921acc54..8df7df18 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -102,6 +102,7 @@ def complete_two_factor_process(user, primary_method, totp_secret, is_changing): # if we are changing two-factor method dologin = False if is_changing: + # As of 5.5.0 this is the legacy path (using session data) completion_message = "TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL" tf_profile_changed.send( current_app._get_current_object(), diff --git a/flask_security/views.py b/flask_security/views.py index 2e0516c7..75e579b1 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -28,6 +28,7 @@ """ +from __future__ import annotations from functools import partial import time import typing as t @@ -35,6 +36,7 @@ from flask import ( Blueprint, after_this_request, + current_app, jsonify, request, session, @@ -67,6 +69,7 @@ from .passwordless import login_token_status, send_login_instructions from .proxies import _security, _datastore from .quart_compat import get_quart_status +from .signals import tf_profile_changed from .unified_signin import ( us_signin, us_signin_send_code, @@ -97,6 +100,7 @@ from .utils import ( base_render_json, check_and_update_authn_fresh, + check_and_get_token_status, config_value as cv, do_flash, get_identity_attributes, @@ -106,6 +110,7 @@ get_post_register_redirect, get_post_verify_redirect, get_request_attr, + get_within_delta, get_url, handle_already_auth, hash_password, @@ -154,7 +159,7 @@ def _ctx(endpoint): @unauth_csrf() -def login() -> "ResponseValue": +def login() -> ResponseValue: """View function for login view""" form = t.cast(LoginForm, build_form_from_request("login_form")) @@ -278,7 +283,7 @@ def logout(): @anonymous_user_required @unauth_csrf() -def register() -> "ResponseValue": +def register() -> ResponseValue: """View function which handles a registration request.""" # For some unknown historic reason - if you don't require confirmation @@ -733,9 +738,9 @@ def two_factor_setup(): """ form = t.cast(TwoFactorSetupForm, build_form_from_request("two_factor_setup_form")) - if not is_user_authenticated(current_user): + changing = is_user_authenticated(current_user) + if not changing: # This is the initial login case - # We can also get here from setup if they want to change TODO: how? if not all(k in session for k in ["tf_user_id", "tf_state"]) or session[ "tf_state" ] not in ["setup_from_login", "validating_profile"]: @@ -785,10 +790,20 @@ def two_factor_setup(): session["tf_totp_secret"] = totp session["tf_primary_method"] = pm session["tf_state"] = "validating_profile" + # currently - state_token only works for changing TFA - not initial login + state_token = None + if changing: + state = { + "totp_secret": totp, + "method": pm, + "phone": phone, + } + state_token = _security.tf_setup_serializer.dumps(state) json_response = { - "tf_state": "validating_profile", + "tf_state": "validating_profile", # deprecated in 5.5.0 "tf_primary_method": pm, # old "tf_method": pm, + "tf_state_token": state_token, } if phone: # TODO dont save here - wait until complete @@ -839,13 +854,13 @@ def two_factor_setup(): primary_method=localize_callback( _setup_methods_xlate[getattr(user, "tf_primary_method", None)] ), + changing=changing, + state_token=state_token, **qrcode_values, **_ctx("tf_setup"), ) # We get here on GET and POST with failed validation. - # For things like phone number - we've already done one POST - # that succeeded and now it failed - so retain the initial info choices = cv("TWO_FACTOR_ENABLED_METHODS") if (not cv("TWO_FACTOR_REQUIRED")) and user.tf_primary_method is not None: choices.insert(0, "disable") @@ -867,15 +882,90 @@ def two_factor_setup(): two_factor_setup_form=form, two_factor_verify_code_form=code_form, choices=choices, - chosen_method=form.setup.data, + chosen_method=None, primary_method=localize_callback( _setup_methods_xlate[getattr(user, "tf_primary_method", None)] ), + changing=changing, + state_token=None, two_factor_required=cv("TWO_FACTOR_REQUIRED"), **_ctx("tf_setup"), ) +@auth_required(lambda: cv("API_ENABLED_METHODS")) +def two_factor_setup_validate(token: str) -> ResponseValue: + """ + Validate new setup. + The token is the state variable which is signed and timed + and contains all the state that once confirmed will be stored in the user record. + Unlike the code in two_factor_token_validation - this works w/o a session. + It also is JUST for setting up/changing two factor for an authenticated user. + """ + form = t.cast( + TwoFactorVerifyCodeForm, build_form_from_request("two_factor_verify_code_form") + ) + + expired, invalid, state = check_and_get_token_status( + token, "tf_setup", get_within_delta("TWO_FACTOR_SETUP_WITHIN") + ) + if invalid: + m, c = get_message("API_ERROR") + if expired: + m, c = get_message( + "TWO_FACTOR_SETUP_EXPIRED", within=cv("TWO_FACTOR_SETUP_WITHIN") + ) + if invalid or expired: + tf_clean_session() # until we completely remove session based setup/state + if _security._want_json(request): + form.form_errors.append(m) + return base_render_json(form, include_user=False) + do_flash(m, c) + return redirect(url_for_security("two_factor_setup")) + + totp_secret = state["totp_secret"] + method = state["method"] + phone = state["phone"] + form.tf_totp_secret = totp_secret + form.primary_method = method + form.user = current_user + + if form.validate_on_submit(): + tf_clean_session() # until we completely remove session based setup/state + after_this_request(view_commit) + _datastore.tf_set(current_user, method, totp_secret, phone) + # TODO: should validity cookie be removed? extended? left alone? + # Currently - leave it alone - meaning cookie still set. + + tf_profile_changed.send( + current_app._get_current_object(), # type: ignore[attr-defined] + _async_wrapper=current_app.ensure_sync, + user=current_user, + method=method, + ) + + if _security._want_json(request): + return base_render_json( + form, + include_user=False, + additional=dict( + tf_method=method, + tf_primary_method=method, + tf_phone=current_user.tf_phone_number, + ), + ) + else: + do_flash(*get_message("TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL")) + return redirect(get_url(cv("TWO_FACTOR_POST_SETUP_VIEW"))) + + # Code not correct/outdated. + if _security._want_json(request): + return base_render_json(form, include_user=False) + m, c = get_message("TWO_FACTOR_INVALID_TOKEN") + do_flash(m, c) + return redirect(url_for_security("two_factor_setup")) + + @unauth_csrf() def two_factor_token_validation(): """View function for two-factor token validation @@ -1139,6 +1229,11 @@ def create_blueprint(app, state, import_name): methods=["GET", "POST"], endpoint="two_factor_setup", )(two_factor_setup) + bp.route( + two_factor_setup_url + slash_url_suffix(two_factor_setup_url, ""), + methods=["POST"], + endpoint="two_factor_setup_validate", + )(two_factor_setup_validate) bp.route( two_factor_token_validation_url, methods=["GET", "POST"], diff --git a/tests/test_two_factor.py b/tests/test_two_factor.py index f040a324..7e3895dd 100644 --- a/tests/test_two_factor.py +++ b/tests/test_two_factor.py @@ -8,9 +8,10 @@ :license: MIT, see LICENSE for more details. """ -from datetime import timedelta +from datetime import date, timedelta import re +from freezegun import freeze_time import markupsafe from passlib.totp import TOTP import pytest @@ -19,6 +20,7 @@ SQLAlchemyUserDatastore, SmsSenderFactory, reset_password_instructions_sent, + tf_profile_changed, uia_email_mapper, ) from tests.test_utils import ( @@ -33,6 +35,7 @@ get_form_input, get_session, is_authenticated, + json_authenticate, logout, ) @@ -868,6 +871,130 @@ def on_identity_changed(app, identity): assert signalled_identity[0] == user.fs_uniquifier +def test_opt_in_nc(app, client_nc, get_message): + """ + Test tf-setup without cookies + """ + response = json_authenticate(client_nc, "jill@lp.com") + assert response.status_code == 200 + token = response.json["response"]["user"]["authentication_token"] + headers = {"Authentication-Token": token, "Accept": "application/json"} + + sms_sender = SmsSenderFactory.createSender("test") + data = dict(setup="sms", phone="+442083661177") + response = client_nc.post("/tf-setup", json=data, headers=headers) + assert response.status_code == 200 + state_token = response.json["response"]["tf_state_token"] + assert sms_sender.get_count() == 1 + code = sms_sender.messages[0].split()[-1] + + # send bad token + response = client_nc.post( + "/tf-setup/not-a-token", json=dict(code=code), headers=headers + ) + assert response.status_code == 400 + assert response.json["response"]["errors"][0].encode("utf-8") == get_message( + "API_ERROR" + ) + + # send bad code + response = client_nc.post( + f"/tf-setup/{state_token}", json=dict(code=12345), headers=headers + ) + assert response.status_code == 400 + assert response.json["response"]["errors"][0].encode("utf-8") == get_message( + "TWO_FACTOR_INVALID_TOKEN" + ) + + # Validate token - this should complete 2FA setup + @tf_profile_changed.connect_via(app) + def pc(sender, user, method, **kwargs): + assert method == "sms" + assert user.tf_phone_number == "+442083661177" + + response = client_nc.post( + f"/tf-setup/{state_token}", json=dict(code=code), headers=headers + ) + assert response.status_code == 200 + + response = client_nc.get("/tf-setup", headers=headers) + assert response.json["response"]["tf_method"] == "sms" + assert response.json["response"]["tf_phone_number"] == "+442083661177" + + +def test_opt_in_nc_expired(app, client_nc, get_message): + """ + Test tf-setup without cookies - expired token + """ + with freeze_time( + date.today() + timedelta(days=-1) + ): # older than TWO_FACTOR_SETUP_WITHIN + response = json_authenticate(client_nc, "jill@lp.com") + assert response.status_code == 200 + token = response.json["response"]["user"]["authentication_token"] + headers = {"Authentication-Token": token, "Accept": "application/json"} + + sms_sender = SmsSenderFactory.createSender("test") + data = dict(setup="sms", phone="+442083661177") + response = client_nc.post("/tf-setup", json=data, headers=headers) + assert response.status_code == 200 + state_token = response.json["response"]["tf_state_token"] + assert sms_sender.get_count() == 1 + code = sms_sender.messages[0].split()[-1] + + # Validate token - this should complete 2FA setup + response = client_nc.post( + f"/tf-setup/{state_token}", json=dict(code=code), headers=headers + ) + assert response.status_code == 400 + assert response.json["response"]["errors"][0].encode("utf-8") == get_message( + "TWO_FACTOR_SETUP_EXPIRED", + within=app.config["SECURITY_TWO_FACTOR_SETUP_WITHIN"], + ) + + +def test_opt_in_state_token(app, client, get_message): + """ + Test using forms and new state_token approach (rather than sessions to store + intermediate state) + """ + authenticate(client, "jill@lp.com") + + # opt-in for SMS 2FA + sms_sender = SmsSenderFactory.createSender("test") + data = dict(setup="sms", phone="+442083661177") + response = client.post("/tf-setup", data=data, follow_redirects=True) + assert b"Enter code to complete setup" in response.data + assert sms_sender.get_count() == 1 + code = sms_sender.messages[0].split()[-1] + verify_url = get_form_action(response, 1) # this will be with state_token + + # send in bad token + response = client.post( + "/tf-setup/not-a-token", data=dict(code=code), follow_redirects=True + ) + assert check_location(app, response.history[0].location, "/tf-setup") + assert get_message("API_ERROR") in response.data + + # send in bad code + response = client.post(verify_url, data=dict(code=12345), follow_redirects=True) + assert check_location(app, response.history[0].location, "/tf-setup") + assert get_message("TWO_FACTOR_INVALID_TOKEN") in response.data + + # Validate token - this should complete 2FA setup + response = client.post(verify_url, data=dict(code=code), follow_redirects=True) + assert b"You successfully changed" in response.data + assert check_location(app, response.history[0].location, "/tf-setup") + + # Upon completion, session cookie shouldn't have any two factor stuff in it. + session = get_session(response) + assert not tf_in_session(session) + + response = client.get("/tf-setup") + assert b"Disable two factor" in response.data + assert b"Currently setup two-factor method: SMS" in response.data + + def test_opt_out_json(app, client): headers = {"Accept": "application/json", "Content-Type": "application/json"}