Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Add support for secret key rotation #1039

Merged
merged 4 commits into from
Nov 16, 2024
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
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)