From 429a27833e95140db8a5c391368f4d27529da014 Mon Sep 17 00:00:00 2001 From: James Espinosa Date: Thu, 14 Nov 2024 20:04:49 -0800 Subject: [PATCH 1/3] add support for secret key rotation --- flask_security/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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(): From 06bf409c090fb2722a5917bba30bb17b821f87c6 Mon Sep 17 00:00:00 2001 From: James Espinosa Date: Fri, 15 Nov 2024 00:28:37 -0800 Subject: [PATCH 2/3] update docs and add feature tests --- CHANGES.rst | 7 +++++++ docs/configuration.rst | 9 +++++++++ tests/conftest.py | 1 + tests/test_misc.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+) 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..e618155d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -17,6 +17,15 @@ 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 a list of old secret keys that can still be used to unsign tokens + that were created with previous secret keys. + + Default: ``None``. + + .. versionadded:: 5.6.0 + .. py:data:: SECURITY_BLUEPRINT_NAME Specifies the name for the Flask-Security blueprint. 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) From 6a971a61c41af3dcfeb716991d2a988b76be9f6e Mon Sep 17 00:00:00 2001 From: James Espinosa Date: Fri, 15 Nov 2024 14:06:17 -0800 Subject: [PATCH 3/3] update wording in config docs --- docs/configuration.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index e618155d..49f0e242 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -19,10 +19,8 @@ These configuration keys are used globally across all features. .. py:data:: SECRET_KEY_FALLBACKS - This is a list of old secret keys that can still be used to unsign tokens - that were created with previous secret keys. - - Default: ``None``. + 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