Skip to content

Commit

Permalink
Change auth_tokens to be versionable, extendable, expirable.
Browse files Browse the repository at this point in the history
Older auth_token formats still are accepted.

Each auth_token is now versioned and has an optional 'exp' timestamp which will be checked during
validity. A new callback SECURITY_TOKEN_EXPIRE_TIMESTAMP can be set to compute whatever the app needs (and it can be per user).

Use freezegun for testing.
  • Loading branch information
jwag956 committed Feb 20, 2024
1 parent 6d6c15a commit 8d3a225
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 97 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Features & Improvements
- (: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.
Old tokens are still accepted.


Docs and Chores
Expand All @@ -47,6 +49,7 @@ Fixes
return an HTML page even if the request was JSON.
- (: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
++++++
Expand Down
48 changes: 22 additions & 26 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,25 @@ These configuration keys are used globally across all features.

Default: ``None``, meaning the token never expires.

.. py:data:: SECURITY_TOKEN_EXPIRE_TIMESTAMP
A callable that returns a unix timestamp in the future when this specific
authentication token should expire. Returning 0 means no expiration.
It is passed the currently authenticated User so any fields can be used
to customize an expiration time. Of course it is called in a request
context so any information about the current request can also be used.

If BOTH this and :data:`SECURITY_TOKEN_MAX_AGE` are set - the shorter is used.

.. note::
These 2 expiry options work differently - with this one, the actual expire
timestamp is in the auth_token. The signed token (using itsdangerous)
has the timestamp the token was generated. On validation, that is checked
against ``SECURITY_TOKEN_MAX_AGE``. So for MAX_AGE, at the time of
validation, the token hasn't yet been associated with a User.

Default: ``lambda user: 0``

.. py:data:: SECURITY_EMAIL_VALIDATOR_ARGS
Email address are validated and normalized via the ``mail_util_cls`` which
Expand Down Expand Up @@ -295,32 +314,6 @@ These configuration keys are used globally across all features.

.. versionadded:: 4.0.0

.. py:data:: SECURITY_REDIRECT_VALIDATE_MODE
Defines how Flask-Security will attempt to mitigate an open redirect
vulnerability w.r.t. client supplied `next` parameters.
Please see :ref:`redirect_topic` for a complete discussion.

Current options include `"absolute"` and `"regex"`. A list is allowed.


Default: ``["absolute"]``

.. versionadded:: 4.0.2

.. versionchanged:: 5.4.0
Default is now `"absolute"` and now takes a list.

.. py:data:: SECURITY_REDIRECT_VALIDATE_RE
This regex handles known patterns that can be exploited. Basically,
don't allow control characters or white-space followed by slashes (or
back slashes).

Default: ``r"^/{4,}|\\{3,}|[\s\000-\037][/\\]{2,}(?![/\\])|[/\\]([^/\\]|/[^/\\])*[/\\].*"``

.. versionadded:: 4.0.2

.. py:data:: SECURITY_CSRF_PROTECT_MECHANISMS
Authentication mechanisms that require CSRF protection.
Expand Down Expand Up @@ -466,6 +459,8 @@ These configuration keys are used globally across all features.
If set to ``True`` Flask-Security will return generic responses to endpoints
that could be used to enumerate users. Please see :ref:`generic_responses`.

Default: ``False``

.. versionadded:: 5.0.0

Core - Multi-factor
Expand Down Expand Up @@ -567,6 +562,7 @@ 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_MULTI_FACTOR_RECOVERY_CODES`

N.B. To avoid strange behavior, be sure to set the grace period less than
the freshness period.
Expand Down
4 changes: 4 additions & 0 deletions docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ isolating password changes from authentication tokens. That attribute can be cha
Unlike ``fs_uniquifier``, it can be set to ``nullable`` - it will automatically be generated
at first use if null.

Authentication tokens have 2 options for specifying expiry time :data:`SECURITY_TOKEN_MAX_AGE`
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).

.. _two-factor:

Two-factor Authentication
Expand Down
84 changes: 38 additions & 46 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
get_request_attr,
is_user_authenticated,
naive_utcnow,
parse_auth_token,
set_request_attr,
uia_email_mapper,
uia_username_mapper,
Expand Down Expand Up @@ -258,6 +259,7 @@
"TOKEN_AUTHENTICATION_KEY": "auth_token",
"TOKEN_AUTHENTICATION_HEADER": "Authentication-Token",
"TOKEN_MAX_AGE": None,
"TOKEN_EXPIRE_TIMESTAMP": lambda user: 0,
"CONFIRM_SALT": "confirm-salt",
"RESET_SALT": "reset-salt",
"LOGIN_SALT": "login-salt",
Expand Down Expand Up @@ -667,31 +669,15 @@ def _request_loader(request):
token = data.get(args_key, token)

try:
data = _security.remember_token_serializer.loads(
token, max_age=cv("TOKEN_MAX_AGE")
)

# Version 3.x generated tokens that map to data with 3 elements,
# and fs_uniquifier was on last element.
# Version 4.0.0 generates tokens that map to data with only 1 element,
# which maps to fs_uniquifier.
# Here we compute uniquifier_index so that we can pick up correct index for
# matching fs_uniquifier in version 4.0.0 even if token was created with
# version 3.x
uniquifier_index = 0 if len(data) == 1 else 2

tdata = parse_auth_token(token)
if hasattr(_security.datastore.user_model, "fs_token_uniquifier"):
user = _security.datastore.find_user(
fs_token_uniquifier=data[uniquifier_index]
)
user = _security.datastore.find_user(fs_token_uniquifier=tdata["uid"])
else:
user = _security.datastore.find_user(fs_uniquifier=data[uniquifier_index])
if not user.active:
user = None
user = _security.datastore.find_user(fs_uniquifier=tdata["uid"])
except Exception:
user = None
return None

if user and user.verify_auth_token(data):
if user and user.active and user.verify_auth_token(tdata):
set_request_attr("fs_authn_via", "token")
return user

Expand Down Expand Up @@ -838,49 +824,55 @@ def get_auth_token(self) -> str | bytes:
Optionally use a separate uniquifier so that changing password doesn't
invalidate auth tokens.
This data MUST be securely signed using the ``remember_token_serializer``
The returned value is securely signed using the ``remember_token_serializer``
.. versionchanged:: 4.0.0
If user model has ``fs_token_uniquifier`` - use that (raise ValueError
if not set). Otherwise, fallback to using ``fs_uniquifier``.
.. versionchanged:: 5.4.0
New format - a dict with a version string. Add a token-based expiry
option as well as a session id.
"""

tdata: dict[str, t.Any] = dict(ver=str(5))
if hasattr(self, "fs_token_uniquifier"):
if not self.fs_token_uniquifier:
raise ValueError()
data = [str(self.fs_token_uniquifier)]
tdata["uid"] = str(self.fs_token_uniquifier)
else:
data = [str(self.fs_uniquifier)]
return _security.remember_token_serializer.dumps(data)
tdata["uid"] = str(self.fs_uniquifier)
tdata["sid"] = 0 # session id
tdata["exp"] = int(cv("TOKEN_EXPIRE_TIMESTAMP")(self)) # if >0 then shorter of
# :data:SECURITY_MAX_AGE and this.

def verify_auth_token(self, data: str | bytes) -> bool:
"""
Perform additional verification of contents of auth token.
Prior to this being called the token has been validated (via signing)
and has not expired.
# Let application add things
self.augment_auth_token(tdata)

:param data: the data as formulated by :meth:`get_auth_token`
# Serialize and sign
return _security.remember_token_serializer.dumps(tdata)

.. versionadded:: 3.3.0
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()
.. versionchanged:: 4.0.0
If user model has ``fs_token_uniquifier`` - use that otherwise
use ``fs_uniquifier``.
.. versionadded:: 5.4.0
"""
return

# Version 3.x generated tokens that map to data with 3 elements,
# and fs_uniquifier was on last element.
# Version 4.0.0 generates tokens that map to data with only 1 element,
# which maps to fs_uniquifier.
# Here we compute uniquifier_index so that we can pick up correct index for
# matching fs_uniquifier in version 4.0.0 even if token was created with
# version 3.x
uniquifier_index = 0 if len(data) == 1 else 2
def verify_auth_token(self, tdata: dict[str, t.Any]) -> bool:
"""
Override this to perform additional verification of contents of auth token.
Prior to this being called the token has been validated (via signing)
and has not expired (either with MAX_AGE or specific 'exp' value).
if hasattr(self, "fs_token_uniquifier"):
return data[uniquifier_index] == self.fs_token_uniquifier
:param tdata: a dictionary just as in augment_auth_token()
return data[uniquifier_index] == self.fs_uniquifier
.. versionadded:: 3.3.0
.. versionchanged:: 5.4.0
Now receives a dictionary.
"""
return True

def has_role(self, role: str | Role) -> bool:
"""Returns `True` if the user identifies with the specified role.
Expand Down
40 changes: 39 additions & 1 deletion flask_security/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def find_csrf_field_name():
return None


def is_user_authenticated(user: User) -> bool:
def is_user_authenticated(user: User | None) -> bool:
"""
return True is user is authenticated.
Expand Down Expand Up @@ -467,6 +467,44 @@ def do_flash(message: str, category: str) -> None:
flash(message, category)


def parse_auth_token(auth_token: str) -> dict[str, t.Any]:
"""Parse an authentication token.
This will raise an exception if not properly signed or expired
"""
tdata = dict()

# This can raise BadSignature or SignatureExpired exceptions from itsdangerous
raw_data = _security.remember_token_serializer.loads(
auth_token, max_age=config_value("TOKEN_MAX_AGE")
)

# Version 3.x generated tokens that map to data with 3 elements,
# and fs_uniquifier was on last element.
# Version 4.0.0 generates tokens that map to data with only 1 element,
# which maps to fs_uniquifier.
# 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"]):
raise ValueError("Token missing keys")
tdata = raw_data
if ts := tdata.get("exp"):
if ts < int(time.time()):
raise SignatureExpired("token[exp] value expired")
else:
# old tokens that were lists
if len(raw_data) == 1:
# version 4
tdata["ver"] = "4"
tdata["uid"] = raw_data[0]
else:
# version 3
tdata["ver"] = "3"
tdata["uid"] = raw_data[2]

return tdata


def get_url(endpoint_or_url: str, qparams: dict[str, str] | None = None) -> str:
"""Returns a URL if a valid endpoint is found. Otherwise, returns the
provided value.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ low = [
"babel==2.12.1",
"bcrypt==4.0.1",
"bleach==6.0.0",
"freezegun",
"jinja2==3.1.2",
"itsdangerous==2.1.2",
"markupsafe==2.1.2",
Expand Down
1 change: 1 addition & 0 deletions requirements/tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ check-manifest
coverage
cryptography
djlint
freezegun
mongoengine
mongomock
msgcheck
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@ def get_security_payload(self):
# which handles datetime
return {"email": str(self.email), "last_update": self.update_datetime}

def augment_auth_token(self, tdata):
# for testing - if TESTING_AUGMENT_AUTH_TOKEN is set - call that
from flask import current_app

if cb := current_app.config.get("TESTING_AUGMENT_AUTH_TOKEN"):
cb(tdata)

with app.app_context():
db.create_all()

Expand Down
Loading

0 comments on commit 8d3a225

Please sign in to comment.