Django兼容PHP password_hash()遗留密码:有无更简便实现方案?
password_hash() Legacy Passwords Great question—this is an extremely common scenario when migrating from PHP to Django, and there are cleaner ways to handle this without overriding the check_password method on your custom User model. Let’s break down the best approaches and confirm you’re not alone in this problem!
A Cleaner Implementation: Let the Password Hasher Handle Both Formats
Your current solution works, but you can eliminate the User model’s custom check_password method entirely by adjusting your BCryptPasswordHasher to natively recognize both the legacy PHP hash format (no prefix) and Django’s prefixed format. Here’s how to tweak it:
from django.contrib.auth.hashers import BasePasswordHasher, constant_time_compare, mask_hash from collections import OrderedDict class BCryptPHPPasswordHasher(BasePasswordHasher): algorithm = "bcrypt_php" library = ("bcrypt", "bcrypt") rounds = 10 def salt(self): bcrypt = self._load_library() return bcrypt.gensalt(self.rounds) def encode(self, password, salt): bcrypt = self._load_library() password = password.encode() data = bcrypt.hashpw(password, salt) # Keep the encoded format as just the bcrypt string (no prefix) for PHP compatibility return data.decode('ascii') def verify(self, incoming_password, encoded_db_password): bcrypt = self._load_library() incoming_password = incoming_password.encode() # Check if the stored hash has our algorithm prefix if encoded_db_password.startswith(f"{self.algorithm}$"): # Split off the prefix to get the raw bcrypt hash _, raw_hash = encoded_db_password.split('$', 1) else: # No prefix—this is a legacy PHP bcrypt hash raw_hash = encoded_db_password # Bcrypt natively handles $2y$ (PHP) vs $2b$ (Django) format differences computed_hash = bcrypt.hashpw(incoming_password, raw_hash.encode('ascii')) return constant_time_compare(raw_hash.encode('ascii'), computed_hash) def safe_summary(self, encoded): # Handle both prefixed and unprefixed hashes for admin display if encoded.startswith(f"{self.algorithm}$"): _, raw_hash = encoded.split('$', 1) else: raw_hash = encoded _, algostr, work_factor, data = raw_hash.split('$', 3) salt, checksum = data[:22], data[22:] return OrderedDict([ ('algorithm', self.algorithm), ('work factor', work_factor), ('salt', mask_hash(salt)), ('checksum', mask_hash(checksum)), ]) def must_update(self, encoded): # Return False to maintain compatibility with the legacy PHP app return False def harden_runtime(self, password, encoded): if encoded.startswith(f"{self.algorithm}$"): _, raw_hash = encoded.split('$', 1) else: raw_hash = encoded data = raw_hash.split('$') rounds = int(data[2]) diff = 2 ** (self.rounds - rounds) - 1 # Re-use the existing salt for runtime hardening salt = '$'.join(data[:3]) + '$' + data[3][:22] while diff > 0: self.encode(password, salt.encode('ascii')) diff -= 1
Why This Is Better:
- No User Model Overrides: You can revert your custom
Userclass to remove thecheck_passwordmethod, keeping your model logic focused on user data rather than password handling. - Transparent Format Support: The hasher automatically handles both legacy PHP hashes (no prefix) and any new hashes you generate, without extra code.
- Django-Aligned Flow: Follows Django’s intended password hasher pattern, making your code easier to maintain for other Django developers.
Have Others Faced This? Absolutely!
This is a top pain point for PHP-to-Django migrations. Many developers run into the exact same issue with PHP’s password_hash() output—especially the $2y$ variant, which is functionally identical to Django’s default $2b$ bcrypt format (most bcrypt libraries, including the one Django uses, automatically handle these version tags).
Common community solutions mirror the approach above: adjusting the custom hasher to recognize unprefixed legacy hashes, rather than modifying the User model. Some also choose to gradually migrate hashes (rehashing with Django’s format when a user logs in), but since you need to keep PHP compatibility, your current must_update=False is the right call.
Final Settings Tweak
Keep your settings.py configuration the same, just make sure the hasher path matches your updated class name:
AUTH_USER_MODEL = 'custom.User' PASSWORD_HASHERS = [ 'custom.auth.hashers.BCryptPHPPasswordHasher', # Updated class name ]
This setup will let your Django app seamlessly verify both existing PHP-generated passwords and any new ones you create, without touching the legacy PHP app.
内容的提问来源于stack exchange,提问作者Artur Siepietowski

