{
"repo": "django/django",
"instance_id": "django__django-16631",
"base_commit": "9b224579875e30203d079cc2fee83b116d98eb78",
"patch": "diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py\n--- a/django/contrib/auth/__init__.py\n+++ b/django/contrib/auth/__init__.py\n@@ -199,12 +199,26 @@ def get_user(request):\n # Verify the session\n if hasattr(user, \"get_session_auth_hash\"):\n session_hash = request.session.get(HASH_SESSION_KEY)\n- session_hash_verified = session_hash and constant_time_compare(\n- session_hash, user.get_session_auth_hash()\n- )\n+ if not session_hash:\n+ session_hash_verified = False\n+ else:\n+ session_auth_hash = user.get_session_auth_hash()\n+ session_hash_verified = constant_time_compare(\n+ session_hash, session_auth_hash\n+ )\n if not session_hash_verified:\n- request.session.flush()\n- user = None\n+ # If the current secret does not verify the session, try\n+ # with the fallback secrets and stop when a matching one is\n+ # found.\n+ if session_hash and any(\n+ constant_time_compare(session_hash, fallback_auth_hash)\n+ for fallback_auth_hash in user.get_session_auth_fallback_hash()\n+ ):\n+ request.session.cycle_key()\n+ request.session[HASH_SESSION_KEY] = session_auth_hash\n+ else:\n+ request.session.flush()\n+ user = None\n \n return user or AnonymousUser()\n \ndiff --git a/django/contrib/auth/base_user.py b/django/contrib/auth/base_user.py\n--- a/django/contrib/auth/base_user.py\n+++ b/django/contrib/auth/base_user.py\n@@ -5,6 +5,7 @@\n import unicodedata\n import warnings\n \n+from django.conf import settings\n from django.contrib.auth import password_validation\n from django.contrib.auth.hashers import (\n check_password,\n@@ -135,10 +136,18 @@ def get_session_auth_hash(self):\n \"\"\"\n Return an HMAC of the password field.\n \"\"\"\n+ return self._get_session_auth_hash()\n+\n+ def get_session_auth_fallback_hash(self):\n+ for fallback_secret in settings.SECRET_KEY_FALLBACKS:\n+ yield self._get_session_auth_hash(secret=fallback_secret)\n+\n+ def _get_session_auth_hash(self, secret=None):\n key_salt = \"django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash\"\n return salted_hmac(\n key_salt,\n self.password,\n+ secret=secret,\n algorithm=\"sha256\",\n ).hexdigest()\n \n",
"test_patch": "diff --git a/tests/auth_tests/test_basic.py b/tests/auth_tests/test_basic.py\n--- a/tests/auth_tests/test_basic.py\n+++ b/tests/auth_tests/test_basic.py\n@@ -1,3 +1,4 @@\n+from django.conf import settings\n from django.contrib.auth import get_user, get_user_model\n from django.contrib.auth.models import AnonymousUser, User\n from django.core.exceptions import ImproperlyConfigured\n@@ -138,3 +139,26 @@ def test_get_user(self):\n user = get_user(request)\n self.assertIsInstance(user, User)\n self.assertEqual(user.username, created_user.username)\n+\n+ def test_get_user_fallback_secret(self):\n+ created_user = User.objects.create_user(\n+ \"testuser\", \"test@example.com\", \"testpw\"\n+ )\n+ self.client.login(username=\"testuser\", password=\"testpw\")\n+ request = HttpRequest()\n+ request.session = self.client.session\n+ prev_session_key = request.session.session_key\n+ with override_settings(\n+ SECRET_KEY=\"newsecret\",\n+ SECRET_KEY_FALLBACKS=[settings.SECRET_KEY],\n+ ):\n+ user = get_user(request)\n+ self.assertIsInstance(user, User)\n+ self.assertEqual(user.username, created_user.username)\n+ self.assertNotEqual(request.session.session_key, prev_session_key)\n+ # Remove the fallback secret.\n+ # The session hash should be updated using the current secret.\n+ with override_settings(SECRET_KEY=\"newsecret\"):\n+ user = get_user(request)\n+ self.assertIsInstance(user, User)\n+ self.assertEqual(user.username, created_user.username)\n",
"problem_statement": "SECRET_KEY_FALLBACKS is not used for sessions\nDescription\n\t\nI recently rotated my secret key, made the old one available in SECRET_KEY_FALLBACKS and I'm pretty sure everyone on our site is logged out now.\nI think the docs for \u200bSECRET_KEY_FALLBACKS may be incorrect when stating the following:\nIn order to rotate your secret keys, set a new SECRET_KEY and move the previous value to the beginning of SECRET_KEY_FALLBACKS. Then remove the old values from the end of the SECRET_KEY_FALLBACKS when you are ready to expire the sessions, password reset tokens, and so on, that make use of them.\nWhen looking at the Django source code, I see that the \u200bsalted_hmac function uses the SECRET_KEY by default and the \u200bAbstractBaseUser.get_session_auth_hash method does not call salted_hmac with a value for the secret keyword argument.\n",
"hints_text": "Hi! I'm a colleague of Eric's, and we were discussing some of the ramifications of fixing this issue and I thought I'd write them here for posterity. In particular for user sessions, using fallback keys in the AuthenticationMiddleware/auth.get_user(request) will keep existing _auth_user_hash values from before the rotation being seen as valid, which is nice during the rotation period, but without any upgrading of the _auth_user_hash values, when the rotation is finished and the fallback keys are removed, all of those sessions will essentially be invalidated again. So, I think possibly an additional need here is a way to upgrade the cookies when a fallback key is used? Or at least documentation calling out this drawback. Edit: It's possible I'm conflating a cookie value and a session value, but either way I think the principle of what I wrote stands?\nThanks for the report. Agreed, we should check fallback session hashes. Bug in 0dcd549bbe36c060f536ec270d34d9e7d4b8e6c7. In particular for user sessions, using fallback keys in the AuthenticationMiddleware/auth.get_user(request) will keep existing _auth_user_hash values from before the rotation being seen as valid, which is nice during the rotation period, but without any upgrading of the _auth_user_hash values, when the rotation is finished and the fallback keys are removed, all of those sessions will essentially be invalidated again. So, I think possibly an additional need here is a way to upgrade the cookies when a fallback key is used? Or at least documentation calling out this drawback. Edit: It's possible I'm conflating a cookie value and a session value, but either way I think the principle of what I wrote stands? As far as I'm aware, this is a new feature request not a bug in #30360, so we should discuss it separately. Maybe we could call update_session_auth_hash() when a fallback hash is valid \ud83e\udd14",
"created_at": "2023-03-06T15:19:52Z",
"version": "5.0",
"FAIL_TO_PASS": "[\"test_get_user_fallback_secret (auth_tests.test_basic.TestGetUser.test_get_user_fallback_secret)\"]",
"PASS_TO_PASS": "[\"test_get_user (auth_tests.test_basic.TestGetUser.test_get_user)\", \"test_get_user_anonymous (auth_tests.test_basic.TestGetUser.test_get_user_anonymous)\", \"The current user model can be retrieved\", \"Check the creation and properties of a superuser\", \"test_superuser_no_email_or_password (auth_tests.test_basic.BasicTestCase.test_superuser_no_email_or_password)\", \"The current user model can be swapped out for another\", \"The alternate user setting must point to something in the format app.model\", \"The current user model must point to an installed model\", \"test_unicode_username (auth_tests.test_basic.BasicTestCase.test_unicode_username)\", \"Users can be created and can set their password\", \"Users can be created without an email\", \"Default User model verbose names are translatable (#19945)\"]",
"environment_setup_commit": "4a72da71001f154ea60906a2f74898d32b7322a7",
"difficulty": "1-4 hours"
}
Failed testcase code for django__django-16631:
def test_get_user_fallback_secret(self):
created_user = User.objects.create_user(
"testuser", "test@example.com", "testpw"
)
self.client.login(username="testuser", password="testpw")
request = HttpRequest()
request.session = self.client.session
prev_session_key = request.session.session_key
with override_settings(
SECRET_KEY="newsecret",
SECRET_KEY_FALLBACKS=[settings.SECRET_KEY],
):
user = get_user(request)
self.assertIsInstance(user, User)
self.assertEqual(user.username, created_user.username)
self.assertNotEqual(request.session.session_key, prev_session_key)
with override_settings(SECRET_KEY="newsecret"):
user = get_user(request)
self.assertIsInstance(user, User)
self.assertEqual(user.username, created_user.username)