Solution TL;DR
Well, it seems that if you use the Argon2 hasher, Django does indeed update the stored password and if you want to avoid it temporarily you must update to Django 3.1 until ready to move to 3.2 or subclass the hasher.
Comparing stored passwords
Since I can access the production database (django 2.2) and the local one when in Django 3, I compare the passwords on my user:
Django 2.2 (released April 2019)
algorithm: argon2 type: argon2i version: 19 memory cost: 512 time cost: 2 parallelism: 2 salt: UVn ********** hash: QVt *****************
Django 3.2
algorithm: argon2 variety: argon2id version: 19 memory cost: 102,400 time cost: 2 parallelism: 8 salt: pHQzc2 **************** hash: flj ****************
Yes, they are different!
Django 3.2 release notes tell about changes in the default Argon2 hasher:
django.contrib.auth
The default iteration count for the PBKDF2 password hasher is increased from 216,000 to 260,000.
The default variant for the Argon2 password hasher is changed to Argon2id. memory_cost and parallelism are increased to 102,400 and 8
respectively to match the argon2-cffi defaults.
Increasing the memory_cost pushes the required memory from 512 KB to 100 MB. This is still rather conservative but can lead to problems in
memory constrained environments. If this is the case, the existing
hasher can be subclassed to override the defaults.
The default salt entropy for the Argon2, MD5, PBKDF2, SHA-1 password hashers is increased from 71 to 128 bits.
This is the ticket of that change:
#30472 Argon2id should be supported and become the default variety for Argon2PasswordHasher
And looking at the code in django.contrib.auth.hashers, I can see that the passwords are modified on check_password:
def check_password(password, encoded, setter=None, preferred='default'):
...
hasher_changed = hasher.algorithm != preferred.algorithm
must_update = hasher_changed or preferred.must_update(encoded)
is_correct = hasher.verify(password, encoded)
...
class Argon2PasswordHasher(BasePasswordHasher):
...
def must_update(self, encoded):
decoded = self.decode(encoded)
current_params = decoded['params']
new_params = self.params()
...
def params(self):
argon2 = self._load_library()
# salt_len is a noop, because we provide our own salt.
return argon2.Parameters(
type=argon2.low_level.Type.ID,
version=argon2.low_level.ARGON2_VERSION,
salt_len=argon2.DEFAULT_RANDOM_SALT_LENGTH,
hash_len=argon2.DEFAULT_HASH_LENGTH,
time_cost=self.time_cost,
memory_cost=self.memory_cost,
parallelism=self.parallelism,
)
This line type=argon2.low_level.Type.ID changes argon2 type from argon2i to argon2id. The rest of the changes are clear.
I`m not sure if this the real process but I guess it goes something like this:
- You enter the password
- The password is checked with the old algorithm
- If it matches, it is rehashed with the new algorithm and saved
(I'll be glad to know if I'm wrong)
To recap: My problem
My problem is that I have several different Django 2 projects using the same common core code and the same database.
Although they are different projects, there are many users who have access to all of them.
I want to update them progressively starting with the least sensitive to see if errors arise.
Solution 1
Upgrade to Django 3.1 instead of 3.2. That would allow me to update the different projects progressively without breaking user access along the way.
Once all the projects have been running for a while with version 3.1 and fixed any bugs that have emerged, I can update them all at the same time to django 3.2 with greater confidence. This is what I have tested and works (it doesn't change passwords).
Solution 2
Subclass the django.contrib.auth.hashers.Argon2PasswordHasher hasher so it doesn't update passwords. Point to it in the PASSWORD_HASHERS settings and remove it when all projects are running smoothly in django 3.2.