diff --git a/CHANGES.rst b/CHANGES.rst index 083847fe..b6fea500 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 ------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index 3355574f..49f0e242 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -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. diff --git a/flask_security/core.py b/flask_security/core.py index 6f3c37ff..78259f67 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -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(): diff --git a/tests/conftest.py b/tests/conftest.py index 1061cef0..85c8852d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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" diff --git a/tests/test_misc.py b/tests/test_misc.py index 4d438184..0fb43d01 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -19,6 +19,7 @@ import pytest +from itsdangerous import BadTimeSignature from wtforms.validators import DataRequired, Length from tests.test_utils import ( @@ -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 @@ -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)