Skip to content

Commit

Permalink
feat(core): Add support for secret key rotation (#1039)
Browse files Browse the repository at this point in the history
* add support for secret key rotation

* update docs and add feature tests

* update wording in config docs

---------

Co-authored-by: Chris Wagner <jwag.wagner@gmail.com>
  • Loading branch information
jamesejr and jwag956 authored Nov 16, 2024
1 parent 7f3977d commit 17ff0eb
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 1 deletion.
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ Flask-Security Changelog

Here you can see the full list of changes between each Flask-Security release.

Version 5.6.0
-------------

Features & Improvements
+++++++++++++++++++++++
- (:issue:`1038`) Add support for 'secret_key' rotation

Version 5.5.2
-------------

Expand Down
7 changes: 7 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ These configuration keys are used globally across all features.
This is actually part of Flask - but is used by Flask-Security to sign all tokens.
It is critical this is set to a strong value. For python3 consider using: ``secrets.token_urlsafe()``

.. py:data:: SECRET_KEY_FALLBACKS
This is part of Flask (>=3.1) but can be used by Flask-Security to unsign tokens.
See Flask documentation https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY_FALLBACKS

.. versionadded:: 5.6.0

.. py:data:: SECURITY_BLUEPRINT_NAME
Specifies the name for the Flask-Security blueprint.
Expand Down
7 changes: 6 additions & 1 deletion flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,8 +801,13 @@ def _get_hashing_context(app: flask.Flask) -> CryptContext:

def _get_serializer(app, name):
secret_key = app.config.get("SECRET_KEY")
derived_keys = app.config.get("SECRET_KEY_FALLBACKS")

secret_keys = [secret_key] + (
derived_keys if isinstance(derived_keys, list) else []
)
salt = cv(f"{name.upper()}_SALT", app=app)
return URLSafeTimedSerializer(secret_key=secret_key, salt=salt)
return URLSafeTimedSerializer(secret_keys, salt=salt)


def _context_processor():
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def app(request: pytest.FixtureRequest) -> SecurityFixture:
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

app.config["SECURITY_PASSWORD_SALT"] = "salty"
app.config["SECURITY_CONFIRM_SALT"] = "confirm-salty"
# Make this fasthash for most tests - reduces unit test time by 50%
app.config["SECURITY_PASSWORD_SCHEMES"] = ["fasthash", "argon2", "bcrypt"]
app.config["SECURITY_PASSWORD_HASH"] = "fasthash"
Expand Down
31 changes: 31 additions & 0 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import pytest

from itsdangerous import BadTimeSignature
from wtforms.validators import DataRequired, Length

from tests.test_utils import (
Expand Down Expand Up @@ -67,6 +68,7 @@
uia_phone_mapper,
verify_hash,
)
from flask_security.core import _get_serializer

if t.TYPE_CHECKING: # pragma: no cover
from flask.testing import FlaskClient
Expand Down Expand Up @@ -1521,3 +1523,32 @@ def test_simplify_url():
assert s == "/login"
s = simplify_url("https:/myhost/profile", "https://localhost/login")
assert s == "https://localhost/login"


@pytest.mark.parametrize(
"verify_secret_key, verify_fallbacks, should_pass",
[
("new_secret", [], False), # Should fail - only new key
("new_secret", ["old_secret"], True), # Should pass - has fallback
("old_secret", [], True), # Should pass - using original key
("wrong_secret", ["also_wrong"], False), # Should fail - no valid keys
],
ids=["new-key-only", "with-fallback", "original-key", "wrong-keys"],
)
def test_secret_key_fallbacks(app, verify_secret_key, verify_fallbacks, should_pass):
# Create token with original key
app.config["SECRET_KEY"] = "old_secret"
serializer = _get_serializer(app, "CONFIRM")
token = serializer.dumps({"data": "test"})

# Attempt verification with different key configurations
app.config["SECRET_KEY"] = verify_secret_key
app.config["SECRET_KEY_FALLBACKS"] = verify_fallbacks
serializer = _get_serializer(app, "CONFIRM")

if should_pass:
data = serializer.loads(token)
assert data["data"] == "test"
else:
with pytest.raises(BadTimeSignature):
serializer.loads(token)

0 comments on commit 17ff0eb

Please sign in to comment.