Skip to content

Commit

Permalink
Chore - tests and docs
Browse files Browse the repository at this point in the history
- improve test speed by using freezegun instead of sleep()
- add tests for turning off freshness validation (make sure auth_tokens work w/o session)
- improve docs around freshness
- add tests to webauthn to make sure returns auth_token

closes #871
  • Loading branch information
jwag956 committed Feb 21, 2024
1 parent cfc66b4 commit 2b52501
Show file tree
Hide file tree
Showing 13 changed files with 143 additions and 113 deletions.
31 changes: 16 additions & 15 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ Features & Improvements
- (:pr:`877`) Make AnonymousUser optional and deprecated.
- (:pr:`906`) Remove undocumented and untested looking in session for possible 'next'
redirect location.
- (:pr:`901`) Work with py_webauthn 2.0
- (:pr:`901`) Work with py_webauthn 2.0 (and only 2.0+)
- (:pr:`881`) No longer rely on Flask-Login.unauthorized callback. See below for implications.
- (:pr:`899`) Improve (and simplify) Two-Factor setup. See below for backwards compatability issues and new functionality.
- (:issue:`904`) Changes to default unauthorized handler - remove use of referrer header (see below).
- (:pr:`xxx`) The authentication_token format has changed - adding per-token expiry time and future session ID.
- (:pr:`927`) The authentication_token format has changed - adding per-token expiry time and future session ID.
Old tokens are still accepted.


Expand All @@ -40,27 +40,17 @@ Docs and Chores
Fixes
+++++

- (:issue:`845`) us-signin magic link should use fs_uniquifier. (not email)
- (:issue:`845`) us-signin magic link should use fs_uniquifier (not email).
- (:issue:`893`) Improve open-redirect vulnerability mitigation. (see below)
- (:issue:`875`) user_datastore.create_user has side effects on mutable inputs. (NoRePercussions)
- (:pr:`878`) The long deprecated _unauthorized_callback/handler has been removed.
- (:issue:`884`) Oauth re-used POST_LOGIN_VIEW which caused confusion. See below for the new configuration and implications.
- (:pr:`908`) Improve CSRF documentation and testing. Fix bug where a CSRF failure could
return an HTML page even if the request was JSON.
- (:issue:`925`) Register with JSON and authentication token failed CSRF. (lilz-egoto)
- (:issue:`870`) Fix 2 issues with CSRF configuration.
- (:pr:`914`) It was possible that if :data:`SECURITY_EMAIL_VALIDATOR_ARGS` were set that
deliverability would be checked even for login.
- (:issue:`925`) Register with JSON and authentication token failed CSRF. (lilz-egoto)

Notes
++++++
- Historically, the **current_user** proxy (managed by Flask-Login) always pointed to a user object.
If the user wasn't authenticated, it pointed to an AnonymousUser object. With this release,
setting :py:data:`SECURITY_ANONYMOUS_USER_DISABLED` to `True` will force **current_user** to be set
to `None` if the requesting user isn't authenticated. It should be noted that this is in support
of a proposal by the Pallets team to remove AnonymousUser from Flask-Login - as well as deprecating
the `is_authenticated` property. The default behavior (`False`) should be the same as prior releases.
A new function `_fs_is_user_authenticated` is now part of the render_template context that
templates can use instead of `current_user.is_authenticated`.

Backwards Compatibility Concerns
+++++++++++++++++++++++++++++++++
Expand Down Expand Up @@ -131,6 +121,17 @@ Backwards Compatibility Concerns
will never redirect to it. If an application needs that, it can provide a callable that can return
that or any other header.

Notes
++++++
- Historically, the **current_user** proxy (managed by Flask-Login) always pointed to a user object.
If the user wasn't authenticated, it pointed to an AnonymousUser object. With this release,
setting :py:data:`SECURITY_ANONYMOUS_USER_DISABLED` to `True` will force **current_user** to be set
to `None` if the requesting user isn't authenticated. It should be noted that this is in support
of a proposal by the Pallets team to remove AnonymousUser from Flask-Login - as well as deprecating
the `is_authenticated` property. The default behavior (`False`) should be the same as prior releases.
A new function `_fs_is_user_authenticated` is now part of the render_template context that
templates can use instead of `current_user.is_authenticated`.

Version 5.3.3
-------------

Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ These configuration keys are used globally across all features.
.. py:data:: SECURITY_CSRF_COOKIE_NAME
The name for the CSRF cookie. This usually should be dictated by your
client-side code - more information can be found at :ref:`csrftopic`
client-side code - more information can be found at :ref:`csrf_topic`

Default: ``None`` - meaning no cookie will be sent.

Expand Down Expand Up @@ -547,7 +547,8 @@ These are used by the Two-Factor and Unified Signin features.

.. note::
This stores freshness information in the session - which must be presented
(usually via a Cookie) to the above endpoints.
(usually via a Cookie) to the above endpoints. To disable this, set it
to ``timedelta(minutes=-1)``

Default: timedelta(hours=24)

Expand Down
9 changes: 7 additions & 2 deletions docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Token Authentication
--------------------

Token based authentication can be used by retrieving the user auth token from an
authentication endpoint (e.g. ``/login``, ``/us-signin``).
authentication endpoint (e.g. ``/login``, ``/us-signin``, ``/wan-signin``).
Perform an HTTP POST with a query param of ``include_auth_token`` and the authentication details
as JSON data.
A successful call will return the authentication token. This token can be used in subsequent
Expand All @@ -94,6 +94,11 @@ Authentication tokens have 2 options for specifying expiry time :data:`SECURITY_
is applied to ALL authentication tokens. Each authentication token can itself have an embedded
expiry value (settable via the :data:`SECURITY_TOKEN_EXPIRE_TIMESTAMP` callable).

.. 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`

.. _two-factor:

Two-factor Authentication
Expand Down Expand Up @@ -249,7 +254,7 @@ JSON/Ajax Support
-----------------

Flask-Security supports JSON/Ajax requests where appropriate. Please
look at :ref:`csrftopic` for details on how to work with JSON and
look at :ref:`csrf_topic` for details on how to work with JSON and
Single Page Applications. More specifically
JSON is supported for the following operations:

Expand Down
4 changes: 3 additions & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,8 @@ paths:
type: string
post:
summary: Sign in using a WebAuthn key - Step 2
parameters:
- $ref: "#/components/parameters/include_auth_token"
requestBody:
required: true
content:
Expand All @@ -1635,7 +1637,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/DefaultJsonResponse"
$ref: "#/components/schemas/JsonResponseWithToken"
302:
description: >
Validation failed - since this form is often auto-submitted
Expand Down
26 changes: 21 additions & 5 deletions docs/patterns.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ at the end of the request. Most (all?) browsers intercept this response and pop
This effectively bypasses any of the normal Flask-Security login forms. By default, the Flask-Security endpoints that require the caller be
authenticated do NOT support ``basic`` - however the :py:data:`SECURITY_API_ENABLED_METHODS` can be used to override this.

.. _freshness_topic:

Freshness
++++++++++
~~~~~~~~~
A common pattern for browser-based sites is to use sessions to manage identity. This is usually
implemented using session cookies. These cookies expire once the session (browser tab) is closed. This is very
convenient, and keeps the users from having to constantly re-authenticate. The downside is that sessions can easily be
Expand All @@ -88,10 +90,24 @@ can be protected by requiring a 'fresh' or recent authentication. Flask-Security
- :func:`.auth_required` takes parameters that define how recent the authentication must have happened. In addition a grace
period can be specified so that multiple step operations don't require re-authentication in the middle.
- A default :meth:`.Security.reauthn_handler` that is called when a request fails the recent authentication check.
- :py:data:`SECURITY_VERIFY_URL` and :py:data:`SECURITY_US_VERIFY_URL` endpoints that request the user to re-authenticate.
- ``VerifyForm`` and ``UsVerifyForm`` forms that can be extended.
- :py:data:`SECURITY_VERIFY_URL`, :py:data:`SECURITY_US_VERIFY_URL`, :py:data:`SECURITY_WAN_VERIFY_URL` endpoints
that request the user to re-authenticate.
- ``VerifyForm``, ``UsVerifyForm``, ``WebAuthnVerifyForm`` forms that can be extended.

Flask-Security itself uses this as part of securing the following endpoints:

- .wan_register ("/wan-register")
- .wan_delete ("/wan-delete")
- .tf_setup ("/tf-setup")
- .us_setup ("/us-setup")
- .mf_recovery_codes ("/mf-recovery-codes")

Using the :py:data:`SECURITY_FRESHNESS` and :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` configuration variables.

Flask-Security itself uses this as part of securing the :ref:`unified-sign-in`, :ref:`two-factor`, and :ref:`webauthn` setup endpoints.
.. tip::
Freshness requires a session (cookie) be sent as part of the request. Without
a session, freshness will fail. If your application doesn't/can't send session cookies
you can disable freshness by setting ``SECURITY_FRESHNESS`` to ``timedelta(minutes=-1)``

.. _redirect_topic:

Expand Down Expand Up @@ -194,7 +210,7 @@ to set :py:data:`SECURITY_WAN_ALLOW_USER_HINTS` to ``False``.

.. _cheat-sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-and-error-messages

.. _csrftopic:
.. _csrf_topic:

CSRF
~~~~
Expand Down
2 changes: 1 addition & 1 deletion docs/spa.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Client side authentication options
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Depending on your SPA architecture and vision you can choose between cookie or token based authentication.

For both there is more documentation and some examples. In both cases, you need to understand and handle :ref:`csrftopic` concerns.
For both there is more documentation and some examples. In both cases, you need to understand and handle :ref:`csrf_topic` concerns.

Security Considerations
~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,7 @@ def verify_auth_token(self, tdata: dict[str, t.Any]) -> bool:
and has not expired (either with MAX_AGE or specific 'exp' value).
:param tdata: a dictionary just as in augment_auth_token()
:return: True if auth token represented by tdata is valid, False otherwise.
.. versionadded:: 3.3.0
Expand Down
36 changes: 19 additions & 17 deletions tests/test_confirmable.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
Confirmable tests
"""

from datetime import date, timedelta
import re
import time
from urllib.parse import parse_qsl, urlsplit

import pytest
from flask import Flask
from freezegun import freeze_time
from wtforms.fields import StringField
from wtforms.validators import Length

Expand Down Expand Up @@ -185,14 +186,14 @@ def test_requires_confirmation_error_redirect(app, clients):
@pytest.mark.registerable()
@pytest.mark.settings(confirm_email_within="1 milliseconds")
def test_expired_confirmation_token(client, get_message):
with capture_registrations() as registrations:
data = dict(email="[email protected]", password="awesome sunset", next="")
client.post("/register", data=data, follow_redirects=True)

email = registrations[0]["email"]
token = registrations[0]["confirm_token"]
# Note that we need relatively new-ish date since session cookies also expire.
with freeze_time(date.today() + timedelta(days=-1)):
with capture_registrations() as registrations:
data = dict(email="[email protected]", password="awesome sunset", next="")
client.post("/register", data=data, follow_redirects=True)

time.sleep(1)
email = registrations[0]["email"]
token = registrations[0]["confirm_token"]

response = client.get("/confirm/" + token, follow_redirects=True)
msg = get_message("CONFIRMATION_EXPIRED", within="1 milliseconds", email=email)
Expand Down Expand Up @@ -416,15 +417,16 @@ def test_spa_get(app, client, get_message):
def test_spa_get_bad_token(app, client, get_message):
"""Test expired and invalid token"""
with capture_flashes() as flashes:
with capture_registrations() as registrations:
response = client.post(
"/register",
json=dict(email="[email protected]", password="awesome sunset"),
headers={"Content-Type": "application/json"},
)
assert response.headers["Content-Type"] == "application/json"
token = registrations[0]["confirm_token"]
time.sleep(1)
# Note that we need relatively new-ish date since session cookies also expire.
with freeze_time(date.today() + timedelta(days=-1)):
with capture_registrations() as registrations:
response = client.post(
"/register",
json=dict(email="[email protected]", password="awesome sunset"),
headers={"Content-Type": "application/json"},
)
assert response.headers["Content-Type"] == "application/json"
token = registrations[0]["confirm_token"]

response = client.get("/confirm/" + token)
assert response.status_code == 302
Expand Down
54 changes: 15 additions & 39 deletions tests/test_csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
"""

from contextlib import contextmanager
import time
from datetime import date, timedelta

import flask_wtf.csrf

import pytest
from flask_wtf import CSRFProtect
from freezegun import freeze_time

from flask_security import Security, hash_password
from tests.test_utils import get_form_input, get_session, logout
Expand Down Expand Up @@ -229,14 +230,11 @@ def test_reset(app, client):


@pytest.mark.recoverable()
@pytest.mark.csrf(csrfprotect=True)
def test_cp_reset(app, client):
"""Test that header based CSRF works for /reset when
using WTF_CSRF_CHECK_DEFAULT=False.
"""
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
CSRFProtect(app)

with mp_validate_csrf() as mp:
data = dict(email="[email protected]")
# should fail - no CSRF token
Expand Down Expand Up @@ -348,16 +346,10 @@ def test_cp_config2(app, sqlalchemy_datastore):


@pytest.mark.changeable()
@pytest.mark.csrf(csrfprotect=True)
@pytest.mark.settings(CSRF_PROTECT_MECHANISMS=["basic", "session"])
def test_different_mechanisms(app, sqlalchemy_datastore):
def test_different_mechanisms(app, client):
# Verify that using token doesn't require CSRF, but sessions do
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
CSRFProtect(app)
app.security = Security(app=app, datastore=sqlalchemy_datastore)
create_user(app)

client = app.test_client()
with mp_validate_csrf() as mp:
auth_token, csrf_token = json_login(client)

Expand Down Expand Up @@ -446,15 +438,9 @@ def test_cp_with_token_empty_mechanisms(app, client):
assert response.status_code == 200


@pytest.mark.csrf(csrfprotect=True)
@pytest.mark.settings(csrf_ignore_unauth_endpoints=True, CSRF_COOKIE_NAME="XSRF-Token")
def test_csrf_cookie(app, sqlalchemy_datastore):
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
CSRFProtect(app)
app.security = Security(app=app, datastore=sqlalchemy_datastore)
create_user(app)

client = app.test_client()
def test_csrf_cookie(app, client):
json_login(client)
assert client.get_cookie("XSRF-Token")

Expand Down Expand Up @@ -491,23 +477,17 @@ def test_cp_with_token_cookie(app, client):
assert not client.get_cookie("XSRF-Token")


@pytest.mark.csrf(csrfprotect=True)
@pytest.mark.app_settings(wtf_csrf_time_limit=1)
@pytest.mark.settings(CSRF_COOKIE_NAME="XSRF-Token", csrf_ignore_unauth_endpoints=True)
@pytest.mark.changeable()
def test_cp_with_token_cookie_expire(app, sqlalchemy_datastore):
def test_cp_with_token_cookie_expire(app, client):
# Make sure that we get a new Csrf-Token cookie if expired.
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_TIME_LIMIT"] = 1
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
CSRFProtect(app)
app.security = Security(app=app, datastore=sqlalchemy_datastore)
create_user(app)

client = app.test_client()

json_login(client, use_header=True)
# Note that we need relatively new-ish date since session cookies also expire.
with freeze_time(date.today() + timedelta(days=-1)):
json_login(client, use_header=True)

# sleep so make csrf_token expires
time.sleep(2)
# time unfrozen so should be expired
data = dict(
password="password",
new_password="battery staple",
Expand Down Expand Up @@ -600,16 +580,12 @@ def test_remember_login_csrf_cookie(app, client):

@pytest.mark.csrf(csrfprotect=True)
@pytest.mark.registerable()
@pytest.mark.settings(csrf_header="X-CSRF-Token")
def test_json_register_csrf_with_ignore_unauth_set_to_false(app, client):
"""
Test that you are able to register a user when using the JSON api
and the CSRF_IGNORE_UNAUTH_ENDPOINTS is set to False.
"""
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
app.config["CSRF_IGNORE_UNAUTH_ENDPOINTS"] = False
app.config["SECURITY_CSRF_HEADER"] = "X-CSRF-Token"
CSRFProtect(app)

csrf_token = client.get("/login", headers={"Accept": "application/json"}).json[
"response"
Expand Down
Loading

0 comments on commit 2b52501

Please sign in to comment.