Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2046,13 +2046,13 @@ Refresh Tokens
Default: ``"/refresh-token"``.
.. py:data:: SECURITY_REFRESH_TOKEN_MAX_AGE

Specifies a timedelta used to compute the tokens expiration date.
Specifies a timedelta used to compute the refresh tokens expiration date.

Default: ``timedelta(days=90)``
.. py:data:: SECURITY_REFRESH_TOKEN_MAX_IDLE

Specifies a timedelta used to compute the date the refresh token will
considered expired due to none use.
considered expired due to non-use.

Default: ``timedelta(days=7)``
.. py:data:: SECURITY_REFRESH_TOKEN_CLEANUP_EXPIRED
Expand All @@ -2067,6 +2067,21 @@ Refresh Tokens
whenever the user creates a new one (re-authenticates)

Default: ``False``
.. py:data:: SECURITY_REFRESH_COOKIE_NAME

If set then refresh tokens will be returned in a cookie rather than as
part of the JSON response. Upon logout, the cookie will be deleted.

Default: ``fs_refresh``

.. py:data:: SECURITY_REFRESH_TOKEN_COOKIE

A dict that defines the parameters required to
set the refresh token cookie.
The complete set of parameters is described in Flask's `set_cookie`_ documentation.
``"httponly"`` should ALWAYS be set.

Default: ``{"samesite": "Strict", "httponly": True, "secure": True}``

Feature Flags
-------------
Expand Down
8 changes: 6 additions & 2 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ paths:
summary: Log out current user
description: >
If a refresh-token is supplied, it will be revoked as part
of logging the user out.
of logging the user out. If SECURITY_REFRESH_TOKEN_COOKIE_NAME is configured,
the refresh token will be deleted as part of logout.
requestBody:
required: false
content:
Expand Down Expand Up @@ -2211,7 +2212,10 @@ paths:
$ref: "#/components/schemas/RefreshToken"
responses:
200:
description: Refresh token response
description: >
Refresh token response. If SECURITY_REFRESH_TOKEN_COOKIE_NAME is configured,
the response will NOT include the refresh token. The refresh token will be
stored in a cookie.
content:
application/json:
schema:
Expand Down
34 changes: 20 additions & 14 deletions docs/patterns.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,43 +89,40 @@ user's UserModel. By default that field is ``fs_uniquifier``. This means that
if that field is changed (via :meth:`.UserDatastore.set_uniquifier`)
then any existing authentication tokens will no longer be valid. This value is changed
whenever a user changes their password. If this is not the desired behavior then you can add an additional
attribute to the UserModel: ``fs_token_uniquifier`` and that will be used instead, thus
isolating password changes from authentication tokens. That attribute can be changed via
:meth:`.UserDatastore.set_token_uniquifier`. This attribute should have ``unique=True``.
field to the UserModel: ``fs_token_uniquifier`` and that will be used instead, thus
isolating password changes from authentication tokens. That field can be changed via
:meth:`.UserDatastore.set_token_uniquifier`. This field should have ``unique=True``.
Unlike ``fs_uniquifier``, it can be set to ``nullable`` - it will automatically be generated
at first use if null.
at first use if None.

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

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

The refresh token feature consists of:

- a DB model - :ref:`FsRefreshTracker <refresh_tracker_model>` to keep track of each refresh token family
- a view :py:data:`SECURITY_REFRESH_TOKEN_URL` that issues a new authentication
token given a valid refresh token
- refresh token rotation
- built-in re-use protection
- return refresh token as a cookie (default) OR in the JSON response
- revoke refresh token/tracker on logout

The refresh token (when enabled) is returned along with the authentication token from any
authentication endpoint. Each new refresh token is tracked in the DB with a FsRefreshTracker entry.
authentication endpoint. By default, it is returned as a cookie (http-only).
Each new refresh token is tracked in the DB with a FsRefreshTracker entry.
Each time a new authentication_token is requested (via the .refresh_token endpoint) the generation
number of the refresh tracker entry is incremented, and a new refresh token is generated and returned.
This effectively makes refresh tokens single use.

The ``.logout`` endpoint takes a ``refresh_token`` as an optional item - if supplied - that refresh token
and the associated tracker will be revoked - this should be the best practice for any application.
and the associated tracker will be revoked - this should be the best practice for any application. If the
refresh token is being managed via a cookie, the cookie will be deleted.

.. _freshness_topic:

Expand Down Expand Up @@ -154,6 +151,8 @@ Flask-Security itself uses this as part of securing the following endpoints:
- .us_setup ("/us-setup")
- .mf_recovery_codes ("/mf-recovery-codes")
- .change_email ("/change-email")
- .change_username ("/change-username")
- .change_password ("/change")

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

Expand Down Expand Up @@ -184,6 +183,8 @@ The following endpoints accept a ``next`` parameter:
- .wan_signin_response ("/wan-signin")
- .us_signin ("/us-signin")
- .us_verify ("/us-verify")
- .oauthstart ("/oauthstart")
- .oauth_verify_start ("/oauth-verify-start")

Flask-Security always quotes the path portion of a user supplied URL.
This `link <https://www.cve.org/CVERecord?id=CVE-2021-32618>`_ provides background of why simple parsing of URLs isn't enough.
Expand Down Expand Up @@ -285,6 +286,11 @@ and the application endpoints (protected with Flask-Security decorators such as
If your application just uses forms that are derived from ``Flask-WTF::Flaskform`` - you are done.
Note that all of Flask-Security's endpoints are form based (regardless of how the request was made).

.. note::
The logout endpoint has never been form-based and has never had CSRF protection. With the addition
of the refresh token feature, logout now accepts form/json input - but still does not support
CSRF protection.

Behind-The-Scenes
++++++++++++++++++
Depending on configuration, there are 3 places CSRF tokens can be checked:
Expand Down Expand Up @@ -371,7 +377,7 @@ the csrf-token, and if it can't, it will check if the request has a header that
Be aware that if you enable this it will ONLY work if you send the session cookie on each request.

.. note::
It is IMPORTANT that you initialize/call ``CSRFProtect`` PRIOR to initializing Flask_Security.
It is IMPORTANT that you initialize/call ``CSRFProtect`` PRIOR to initializing Flask-Security.

.. note::
Calling CSRFProtect(app) will setup a @before_request handler to verify CSRF - this occurs BEFORE any Flask-Security decorators
Expand Down
6 changes: 6 additions & 0 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,12 @@
"REFRESH_TOKEN_CLEANUP_EXPIRED": True,
"REFRESH_TOKEN_CLEANUP_REVOKED": False,
"REFRESH_TOKEN_URL": "/refresh-token",
"REFRESH_TOKEN_COOKIE_NAME": "fs_refresh",
"REFRESH_TOKEN_COOKIE": {
"samesite": "Strict",
"httponly": True,
"secure": True,
},
"CONFIRM_SALT": "confirm-salt",
"RESET_SALT": "reset-salt",
"LOGIN_SALT": "login-salt",
Expand Down
7 changes: 7 additions & 0 deletions flask_security/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,13 @@ class LogoutForm(Form):
refresh_errors: RefreshTokenErrors | None = None
refresh_tracker: RefreshTrackerMixin | None = None

def __init__(self, *args: t.Any, **kwargs: t.Any):
super().__init__(*args, **kwargs)
# If cookie name set - then use that - NOT from the form
if request and cv("REFRESH_TOKEN") and cv("REFRESH_TOKEN_COOKIE_NAME"):
token = request.cookies.get(cv("REFRESH_TOKEN_COOKIE_NAME"), default=None)
self.refresh_token.data = token

def validate(self, **kwargs: t.Any) -> bool:
from .tokens import verify_refresh_token

Expand Down
58 changes: 50 additions & 8 deletions flask_security/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,25 @@

TODO
- add ability for app to add/verify additional stuff in refresh_token
- add support for HTTP-only cookie (watch for CSRF issue)
- add endpoint to user can revoke refresh token?
- change TOKEN_MAX_AGE to accept timedelta and change default to 30minutes or so.
- implment rotation overlap period:
- implment rotation overlap period?:
https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-rotation

"""

from __future__ import annotations

from copy import copy
from enum import Enum, auto
import typing as t
from functools import partial

from flask import abort, request, after_this_request, current_app
from flask import abort, request, after_this_request, current_app, Response
from itsdangerous import BadSignature
from wtforms import StringField

from .decorators import unauth_csrf
from .forms import (
Form,
RequiredLocalize,
Expand All @@ -40,7 +43,13 @@
)
from .proxies import _security, _datastore
from .signals import refresh_tracker_revoked, refresh_tracker_created
from .utils import config_value as cv, _, view_commit, get_message, base_render_json
from .utils import (
config_value as cv,
_,
view_commit,
get_message,
base_render_json,
)

if t.TYPE_CHECKING: # pragma: no cover
from flask.typing import ResponseValue
Expand Down Expand Up @@ -73,7 +82,10 @@ def response_tokens(user: UserMixin, payload: dict[str, t.Any]) -> None:
if _security.refresh_token:
after_this_request(view_commit)
refresh_tracker, refresh_token = new_refresh_tracker(user, name="default")
payload["user"]["refresh_token"] = refresh_token
if cv("REFRESH_TOKEN_COOKIE_NAME"):
after_this_request(partial(set_refresh_token_cookie, token=refresh_token))
else:
payload["user"]["refresh_token"] = refresh_token


def new_refresh_tracker(user: UserMixin, name: str) -> tuple[RefreshTrackerMixin, str]:
Expand Down Expand Up @@ -122,6 +134,7 @@ def get_refresh_token(refresh_tracker: RefreshTrackerMixin, user: UserMixin) ->
"ver": str(1),
"uid": getattr(user, _datastore.get_token_uniquifier_name()),
"expires_at": refresh_tracker.expires_at.timestamp(),
"last_used_at": refresh_tracker.last_used_at.timestamp(),
"name": refresh_tracker.name,
"family": refresh_tracker.refresh_family,
"gen": refresh_tracker.gen,
Expand Down Expand Up @@ -195,6 +208,13 @@ class RefreshTokenForm(Form):
refresh_tracker: RefreshTrackerMixin | None = None
user: UserMixin | None = None

def __init__(self, *args: t.Any, **kwargs: t.Any):
super().__init__(*args, **kwargs)
# If cookie name set - then use that - NOT anything in the form
if request and cv("REFRESH_TOKEN") and cv("REFRESH_TOKEN_COOKIE_NAME"):
token = request.cookies.get(cv("REFRESH_TOKEN_COOKIE_NAME"), default=None)
self.refresh_token.data = token

def validate(self, **kwargs: t.Any) -> bool:
if not super().validate(**kwargs): # pragma: no cover
return False
Expand Down Expand Up @@ -222,6 +242,7 @@ def validate(self, **kwargs: t.Any) -> bool:
return True


@unauth_csrf()
def refresh() -> ResponseValue:
if not request.is_json:
abort(400)
Expand All @@ -238,9 +259,12 @@ def refresh() -> ResponseValue:
payload = dict()
payload["user"] = form.user.get_security_payload()
payload["user"]["authentication_token"] = form.user.get_auth_token()
payload["user"]["refresh_token"] = get_refresh_token(
form.refresh_tracker, form.user
)

refresh_token = get_refresh_token(form.refresh_tracker, form.user)
if cv("REFRESH_TOKEN_COOKIE_NAME"):
after_this_request(partial(set_refresh_token_cookie, token=refresh_token))
else:
payload["user"]["refresh_token"] = refresh_token
return _security._render_json(payload, 200, None, form.user)

# Failed validation - if it was a GEN_MISMATCH this could be a hack and we should
Expand All @@ -249,3 +273,21 @@ def refresh() -> ResponseValue:
assert form.refresh_tracker
_revoke_refresh_tracker(form.refresh_tracker, form.refresh_errors, form.user)
return base_render_json(form, include_user=False)


def set_refresh_token_cookie(response: Response, token: str) -> Response:
cookie_kwargs = copy(cv("REFRESH_TOKEN_COOKIE"))
response.set_cookie(cv("REFRESH_TOKEN_COOKIE_NAME"), value=token, **cookie_kwargs)
# This is likely overkill since so far we only return this on a POST which is
# unlikely to be cached.
response.vary.add("Cookie")
return response


def clear_refresh_token_cookie(response: Response) -> Response:
cookie_kwargs = copy(cv("REFRESH_TOKEN_COOKIE"))
# Alas delete_cookie only accepts some of the keywords set_cookie does
allowed = ["path", "domain", "secure", "httponly", "samesite", "partitioned"]
args = {k: cookie_kwargs.get(k) for k in allowed if k in cookie_kwargs}
response.delete_cookie(cv("REFRESH_TOKEN_COOKIE_NAME"), **args)
return response
7 changes: 6 additions & 1 deletion flask_security/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Flask-Security utils module

:copyright: (c) 2012-2019 by Matt Wright.
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
:copyright: (c) 2019-2026 by J. Christopher Wagner (jwag).
:license: MIT, see LICENSE for more details.
"""

Expand Down Expand Up @@ -34,6 +34,7 @@
render_template,
session,
url_for,
after_this_request,
)
from flask_login import login_user as _login_user
from flask_login import logout_user as _logout_user
Expand Down Expand Up @@ -284,6 +285,10 @@ def logout_user() -> None:
# in 'g'. Be sure to clear both. This affects at least /confirm
g.pop(csrf_field_name, None)
session["fs_cc"] = "clear"
if config_value("REFRESH_TOKEN") and config_value("REFRESH_TOKEN_COOKIE_NAME"):
from .tokens import clear_refresh_token_cookie

after_this_request(partial(clear_refresh_token_cookie))
identity_changed.send(
current_app._get_current_object(), # type: ignore
_async_wrapper=current_app.ensure_sync,
Expand Down
12 changes: 11 additions & 1 deletion flask_security/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
url_for_security,
view_commit,
allowed_auth_token,
set_request_attr,
)
from .webauthn import (
has_webauthn,
Expand Down Expand Up @@ -275,10 +276,19 @@ def verify():


def logout():
"""View function which handles a logout request."""
"""View function which handles a logout request.
logout has never been CSRF protected.
As part of the refresh_token feature, logout now has a form defined
which a client can pass a refresh token that will be revoked as part of logout
(if the refresh token is managed with a cookie, the value will be set into the
form).
The cookie AND refresh tracker/token will be revoked.
"""
tf_clean_session()

if is_user_authenticated(current_user):
# Until we implement logout CSRF - shouldn't logout but NOT revoke refresh token
set_request_attr("csrf_valid", True)
form = t.cast(
LogoutForm, build_form_from_request("logout_form", user=current_user)
)
Expand Down
Loading
Loading