Skip to content

Commit

Permalink
Add freshness to auth tokens
Browse files Browse the repository at this point in the history
This enables us-setup to be totally functional with auth tokens (not requiring a session cookie).

- Authentication tokens now carry freshness information - namely the timestamp that the client/user successfully authenticated.
- Both sessions and auth tokens place the timestamp into a request global - fs_paa
- @auth_required() freshness checking looks at the request global - a config parameter SECURITY_FRESHNESS_ALLOW_AUTH_TOKEN can be set to False which forces any request using an auth token to be 'not-fresh' (the previous behavior).
- Removed code for freshness 'within' == 0 - which was a hack for testing - we have better testing utilities now.
- fixed/improved example for unified signin.
- doc improvements...
  • Loading branch information
jwag956 committed Jun 15, 2024
1 parent 80a7139 commit c44d6a2
Show file tree
Hide file tree
Showing 14 changed files with 175 additions and 81 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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)

Docs and Chores
+++++++++++++++
Expand Down
33 changes: 22 additions & 11 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,9 @@ These are used by the Two-Factor and Unified Signin features.
- :py:data:`SECURITY_US_SETUP_URL`
- :py:data:`SECURITY_TWO_FACTOR_SETUP_URL`
- :py:data:`SECURITY_WAN_REGISTER_URL`
- :py:data:`SECURITY_WAN_DELETE_URL`
- :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES`
- :py:data:`SECURITY_CHANGE_EMAIL_URL`

Setting this to a negative number will disable any freshness checking and
the endpoints:
Expand All @@ -552,9 +554,8 @@ These are used by the Two-Factor and Unified Signin features.
Please see :meth:`flask_security.check_and_update_authn_fresh` for details.

.. note::
This stores freshness information in the session - which must be presented
(usually via a Cookie) to the above endpoints. To disable this, set it
to ``timedelta(minutes=-1)``
The timestamp of when the caller/user last successfully authenticated is
stored in the session as well as authentication token.

Default: timedelta(hours=24)

Expand All @@ -563,13 +564,11 @@ These are used by the Two-Factor and Unified Signin features.
.. py:data:: SECURITY_FRESHNESS_GRACE_PERIOD
A timedelta that provides a grace period when altering sensitive
information.
This is used to protect the endpoints:
information. This ensures that multi-step operations don't get denied
because the session/token happens to expire mid-step.

- :py:data:`SECURITY_US_SETUP_URL`
- :py:data:`SECURITY_TWO_FACTOR_SETUP_URL`
- :py:data:`SECURITY_WAN_REGISTER_URL`
- :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES`
Note that this is not implemented for freshness information carried in the
auth token.

N.B. To avoid strange behavior, be sure to set the grace period less than
the freshness period.
Expand All @@ -579,6 +578,18 @@ These are used by the Two-Factor and Unified Signin features.

.. versionadded:: 3.4.0

.. py:data:: SECURITY_FRESHNESS_ALLOW_AUTH_TOKEN
Controls whether the freshness data set in the auth token can be used to
satisfy freshness checks. Some applications might want to force freshness
protected endpoints to always use browser based access with sessions - they
should set this to ``False``.

Default: ``True``


.. versionadded:: 5.5.0

Core - Compatibility
---------------------
These are flags that change various backwards compatability functionality.
Expand Down Expand Up @@ -1672,8 +1683,8 @@ WebAuthn

Additional relevant configuration variables:

* :py:data:`SECURITY_FRESHNESS` - Used to protect /us-setup.
* :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` - Used to protect /us-setup.
* :py:data:`SECURITY_FRESHNESS` - Used to protect /wan-register and /wan-delete.
* :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` - Used to protect /wan-register and /wan-delete.

Recovery Codes
--------------
Expand Down
7 changes: 6 additions & 1 deletion docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ Token Authentication
--------------------

Token based authentication can be used by retrieving the user auth token from an
authentication endpoint (e.g. ``/login``, ``/us-signin``, ``/wan-signin``).
authentication endpoint (e.g. ``/login``, ``/us-signin``, ``/wan-signin``, ``/verify``,
``/us-verify``, ``/wan-verify``).
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 @@ -101,6 +102,10 @@ 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).

Authentication tokens also convey freshness by recording the time the token was generated.
This is used for endpoints protected with :func:`.auth_required` with a ``within``
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).
Expand Down
9 changes: 6 additions & 3 deletions docs/patterns.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,16 @@ Flask-Security itself uses this as part of securing the following endpoints:
- .tf_setup ("/tf-setup")
- .us_setup ("/us-setup")
- .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.

.. 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)``
The timestamp of the users last successful authentication is stored in the session
as well as in the authentication token. One of these must be presented or freshness
will fail. You can disallow using the value in the authentication token by setting
:py:data:`SECURITY_FRESHNESS_ALLOW_AUTH_TOKEN` to ``False``.
You can disable freshness by setting ``SECURITY_FRESHNESS`` to ``timedelta(minutes=-1)``

.. _redirect_topic:

Expand Down
5 changes: 4 additions & 1 deletion examples/unified_signin/client/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Copyright 2020-2021 by J. Christopher Wagner (jwag). All rights reserved.
Copyright 2020-2024 by J. Christopher Wagner (jwag). All rights reserved.
:license: MIT, see LICENSE for more details.
This relies on session/session cookie for continued authentication.
Expand Down Expand Up @@ -84,6 +84,9 @@ def ussetup(server_url, session, password, phone):
# unified sign in - setup sms with a phone number
# Use the backdoor to grab verification SMS.

# reset freshness to show how that would work
resp = session.get(f"{server_url}/api/resetfresh")

csrf_token = session.cookies["XSRF-TOKEN"]
resp = session.post(
f"{server_url}/us-setup",
Expand Down
15 changes: 14 additions & 1 deletion examples/unified_signin/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from datetime import datetime, timezone

from flask import Blueprint, abort, current_app, jsonify
from flask import Blueprint, abort, current_app, jsonify, session
from flask_security import auth_required

from app import SmsCaptureSender
Expand Down Expand Up @@ -39,3 +39,16 @@ def popsms():
if msg:
return jsonify(sms=msg)
abort(400)


@api.route("/resetfresh", methods=["GET"])
def resetfresh():
# This resets the callers session freshness field - just for testing
old_paa = (
session["fs_paa"]
- current_app.config["SECURITY_FRESHNESS"].total_seconds()
- 100
)
session["fs_paa"] = old_paa
session.pop("fs_gexp", None)
return jsonify()
11 changes: 3 additions & 8 deletions examples/unified_signin/server/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Copyright 2020-2022 by J. Christopher Wagner (jwag). All rights reserved.
Copyright 2020-2024 by J. Christopher Wagner (jwag). All rights reserved.
:license: MIT, see LICENSE for more details.
A simple example of server and client utilizing unified sign in and other
Expand All @@ -13,7 +13,6 @@
"""

import datetime
import os

from flask import Flask
Expand Down Expand Up @@ -75,6 +74,8 @@ def create_app():
# We aren't interested in form-based APIs - so no need for flashing.
app.config["SECURITY_FLASH_MESSAGES"] = False

app.config["SECURITY_AUTO_LOGIN_AFTER_CONFIRM"] = True

# Allow signing in with a phone number or email
app.config["SECURITY_USER_IDENTITY_ATTRIBUTES"] = [
{"email": {"mapper": uia_email_mapper, "case_insensitive": True}},
Expand Down Expand Up @@ -108,12 +109,6 @@ def create_app():
app.config["REMEMBER_COOKIE_SAMESITE"] = "strict"
app.config["SESSION_COOKIE_SAMESITE"] = "strict"

# This means the first 'fresh-required' endpoint after login will always require
# re-verification - but after that the grace period will kick in.
# This isn't likely something a normal app would need/want to do.
app.config["SECURITY_FRESHNESS"] = datetime.timedelta(minutes=0)
app.config["SECURITY_FRESHNESS_GRACE_PERIOD"] = datetime.timedelta(minutes=2)

# As of Flask-SQLAlchemy 2.4.0 it is easy to pass in options directly to the
# underlying engine. This option makes sure that DB connections from the pool
# are still valid. Important for entire application since many DBaaS options
Expand Down
27 changes: 21 additions & 6 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
from datetime import datetime, timedelta
from dataclasses import dataclass
import importlib
import time
import typing as t
import warnings

from flask import current_app, g
from flask import current_app, g, session
from flask_login import AnonymousUserMixin, LoginManager
from flask_login import UserMixin as BaseUserMixin
from flask_login import current_user
Expand Down Expand Up @@ -292,6 +293,7 @@
"PHONE_REGION_DEFAULT": "US",
"FRESHNESS": timedelta(hours=24),
"FRESHNESS_GRACE_PERIOD": timedelta(hours=1),
"FRESHNESS_ALLOW_AUTH_TOKEN": True,
"API_ENABLED_METHODS": ["session", "token"],
"HASHING_SCHEMES": ["sha256_crypt", "hex_md5"],
"DEPRECATED_HASHING_SCHEMES": ["hex_md5"],
Expand Down Expand Up @@ -674,6 +676,7 @@ def _user_loader(user_id):
user = _security.datastore.find_user(fs_uniquifier=str(user_id))
if user and user.active:
set_request_attr("fs_authn_via", "session")
set_request_attr("fs_paa", session.get("fs_paa", 0))
return user
return None

Expand Down Expand Up @@ -707,6 +710,8 @@ def _request_loader(request):

if user and user.active and user.verify_auth_token(tdata):
set_request_attr("fs_authn_via", "token")
if cv("FRESHNESS_ALLOW_AUTH_TOKEN"):
set_request_attr("fs_paa", tdata.get("fs_paa", 0))
return user

return None
Expand Down Expand Up @@ -849,8 +854,12 @@ def get_auth_token(self) -> str | bytes:
:raises ValueError: If ``fs_token_uniquifier`` is part of model but not set.
Optionally use a separate uniquifier so that changing password doesn't
invalidate auth tokens.
Uses ``fs_uniquifier`` or ``fs_token_uniquifier`` (if in the UserModel)
to identify this user. If ``fs_token_uniquifier`` is used then
changing password doesn't invalidate auth tokens.
Calls :meth:`.UserMixin.augment_auth_token` which applications can override
to add any additional information.
The returned value is securely signed using the ``remember_token_serializer``
Expand All @@ -860,6 +869,9 @@ def get_auth_token(self) -> str | bytes:
.. versionchanged:: 5.4.0
New format - a dict with a version string. Add a token-based expiry
option as well as a session id.
.. versionchanged:: 5.5.0
Remove session id (never set or used); added fs_paa (last authentication
timestamp)
"""

tdata: dict[str, t.Any] = dict(ver=str(5))
Expand All @@ -869,7 +881,9 @@ def get_auth_token(self) -> str | bytes:
tdata["uid"] = str(self.fs_token_uniquifier)
else:
tdata["uid"] = str(self.fs_uniquifier)
tdata["sid"] = 0 # session id
# Set the primary authenticated at variable. This is equivalent to
# what we set in the session.
tdata["fs_paa"] = time.time() # equivalent of session["fs_paa"]
tdata["exp"] = int(cv("TOKEN_EXPIRE_TIMESTAMP")(self)) # if >0 then shorter of
# :data:SECURITY_MAX_AGE and this.

Expand All @@ -881,7 +895,8 @@ def get_auth_token(self) -> str | bytes:

def augment_auth_token(self, tdata: dict[str, t.Any]) -> None:
"""Override this to add/modify parts of the auth token.
Additions to the dict can be made and verified in verify_auth_token()
Additions to the dict can be made here and verified in
:meth:`.UserMixin.verify_auth_token`
.. versionadded:: 5.4.0
"""
Expand Down Expand Up @@ -1032,7 +1047,7 @@ def get_user_mapping(self) -> dict[str, t.Any]:
"""
Return the filter needed by find_user() to get the user
associated with this webauthn credential.
Note that this probably has to be overridden using mongoengine.
Note that this probably has to be overridden when using mongoengine.
.. versionadded:: 5.0.0
"""
Expand Down
7 changes: 2 additions & 5 deletions flask_security/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,7 @@ def dashboard():
timedelta.total_seconds() is used for the calculations:
- If > 0, then the caller must have authenticated within the time specified
(as measured using the session cookie).
- If 0 and not within the grace period (see below) the caller will
always be redirected to re-authenticate.
(as measured using the session cookie or authentication token).
- If < 0 (the default) no freshness check is performed.
Note that Basic Auth, by definition, is always 'fresh' and will never result in
Expand Down Expand Up @@ -422,8 +420,7 @@ def decorated_view(
for method, mechanism in mechanisms:
if mechanism and mechanism():
# successfully authenticated. Basic auth is by definition 'fresh'.
# Note that using token auth is ok - but caller still has to pass
# in a session cookie if freshness checking is required.
# If 'within' is set - check for freshness of authentication.
if not check_and_update_authn_fresh(within, grace, method):
return _security._reauthn_handler(within, grace)
if eresponse := handle_csrf(method, _security._want_json(request)):
Expand Down
29 changes: 14 additions & 15 deletions flask_security/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,21 +269,21 @@ def check_and_update_authn_fresh(
will set a grace period for which freshness won't be checked.
The intent here is that the caller shouldn't get part-way though
a set of operations and suddenly be required to authenticate again.
This is not supported for authentication tokens.
:param method: Optional - if set and == "basic" then will always return True.
(since basic-auth sends username/password on every request)
If within.total_seconds() is negative, will always return True (always 'fresh').
This effectively just disables this entire mechanism.
within.total_seconds() == 0 results in undefined behavior.
If "fs_gexp" is in the session and the current timestamp is less than that,
return True and extend grace time (i.e. set fs_gexp to current time + grace).
If not within the grace period, and within.total_seconds() is 0,
return False (not fresh).
Be aware that for this to work, sessions and therefore session cookies
must be functioning and being sent as part of the request. If the required
state isn't in the session cookie then return False (not 'fresh').
Be aware that for this to work, state is required to be sent from the client.
Flask security adds this state to the session (cookie) and the auth token.
Without this state, 'False' is always returned - (not fresh).
.. warning::
Be sure the caller is already authenticated PRIOR to calling this method.
Expand All @@ -292,6 +292,10 @@ def check_and_update_authn_fresh(
.. versionchanged:: 4.0.0
Added `method` parameter.
.. versionchanged:: 5.5.0
Grab 'Primary Authenticated At' from request_attrs
which is set from either session or auth token
"""

if method == "basic":
Expand All @@ -301,8 +305,8 @@ def check_and_update_authn_fresh(
# this means 'always fresh'
return True

if "fs_paa" not in session:
# No session, you can't play.
if not (paa := get_request_attr("fs_paa")):
# No recorded primary authenticated at time, you can't play.
return False

now = naive_utcnow()
Expand All @@ -315,12 +319,7 @@ def check_and_update_authn_fresh(
session["fs_gexp"] = grace_ts
return True

# Special case 0 - return False always, but set grace period.
if within.total_seconds() == 0:
session["fs_gexp"] = grace_ts
return False

authn_time = naive_utcfromtimestamp(session["fs_paa"])
authn_time = naive_utcfromtimestamp(paa)
# allow for some time drift where it's possible authn_time is in the future
# but let's be cautious and not allow arbitrary future times
delta = now - authn_time
Expand Down Expand Up @@ -484,7 +483,7 @@ def parse_auth_token(auth_token: str) -> dict[str, t.Any]:
# Version 5 and up are already a dict (with a version #)
if isinstance(raw_data, dict):
# new format - starting at ver=5
if not all(k in raw_data for k in ["ver", "uid", "exp", "sid"]):
if not all(k in raw_data for k in ["ver", "uid", "exp"]):
raise ValueError("Token missing keys")
tdata = raw_data
if ts := tdata.get("exp"):
Expand Down
Loading

0 comments on commit c44d6a2

Please sign in to comment.