Skip to content

Commit

Permalink
Add tf-setup endpoint that uses a state_token. (#991)
Browse files Browse the repository at this point in the history
/tf-setup now returns a state_token (in addition to prior behavior of setting state in the session).
This state_token can be used at /tf-setup/<state-token> to complete a 2FA setup.
This enables /tf-setup with an authentication token and no session cookie - and follows the
same model as /us-setup.

Notes: actual 2FA code validation during login still requires a session.
The old session way of /tf-setup is still there - no backwards compat issues.
  • Loading branch information
jwag956 authored Jun 18, 2024
1 parent 4c33560 commit 147a612
Show file tree
Hide file tree
Showing 13 changed files with 363 additions and 41 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
+++++++++++++++
Expand All @@ -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
-------------
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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``
Expand Down
3 changes: 2 additions & 1 deletion docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<csrf_topic>`.
As of release 5.5.0, authentication tokens by default carry freshness information.

User Registration
-----------------
Expand Down
102 changes: 82 additions & 20 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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_state_token>'.
tf_primary_method:
type: string
description: Current method being configured (deprecated).
Expand Down Expand Up @@ -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 ]
Expand Down
2 changes: 1 addition & 1 deletion docs/patterns.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/two_factor_configurations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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/<state_token>``.
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
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 2 additions & 0 deletions flask_security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
MfRecoveryCodesForm,
)
from .signals import (
change_email_confirmed,
change_email_instructions_sent,
confirm_instructions_sent,
login_instructions_sent,
password_changed,
Expand Down
8 changes: 8 additions & 0 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion flask_security/templates/security/two_factor_setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,7 +63,12 @@ <h3>{{ _fsdomain("In addition to your username and password, you'll need to use
{# This is the fill in code part #}
<hr class="fs-gap">
<div class="fs-important">{{ _fsdomain("Enter code to complete setup") }}</div>
<form action="{{ url_for_security('two_factor_token_validation') }}" method="post" name="two_factor_verify_code_form">
{% 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 %}
<form action="{{ faction }}" method="post" name="two_factor_verify_code_form">
{{ two_factor_verify_code_form.hidden_tag() }}
{{ render_field_with_errors(two_factor_verify_code_form.code, placeholder=_fsdomain("enter numeric code")) }}
<div class="fs-gap">{{ render_field(two_factor_verify_code_form.submit) }}</div>
Expand Down
1 change: 1 addition & 0 deletions flask_security/twofactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading

0 comments on commit 147a612

Please sign in to comment.