There is a distinct lack of discussion on backwards and forwards compatibility that is built in to PHP's password functions. Notably:
crypt()
, and are inherently backwards-compatible with crypt()
-format hashes, even if they use obsolete and/or insecure hash algorithms.password_needs_rehash()
and a bit of logic into your authentication workflow can keep you your hashes up to date with current and future algorithms with potentially zero future changes to the workflow. Note: Any string that does not match the specified algorithm will be flagged for needing a rehash, including non-crypt-compatible hashes.Eg:
class FakeDB {
public function __call($name, $args) {
printf("%s::%s(%s)\n", __CLASS__, $name, json_encode($args));
return $this;
}
}
class MyAuth {
protected $dbh;
protected $fakeUsers = [
// old crypt-md5 format
1 => ['password' => '$1$AVbfJOzY$oIHHCHlD76Aw1xmjfTpm5.'],
// old salted md5 format
2 => ['password' => '3858f62230ac3c915f300c664312c63f', 'salt' => 'bar'],
// current bcrypt format
3 => ['password' => '$2y$10$3eUn9Rnf04DR.aj8R3WbHuBO9EdoceH9uKf6vMiD7tz766rMNOyTO']
];
public function __construct($dbh) {
$this->dbh = $dbh;
}
protected function getuser($id) {
// just pretend these are coming from the DB
return $this->fakeUsers[$id];
}
public function authUser($id, $password) {
$userInfo = $this->getUser($id);
// Do you have old, turbo-legacy, non-crypt hashes?
if( strpos( $userInfo['password'], '$' ) !== 0 ) {
printf("%s::legacy_hash\n", __METHOD__);
$res = $userInfo['password'] === md5($password . $userInfo['salt']);
} else {
printf("%s::password_verify\n", __METHOD__);
$res = password_verify($password, $userInfo['password']);
}
// once we've passed validation we can check if the hash needs updating.
if( $res && password_needs_rehash($userInfo['password'], PASSWORD_DEFAULT) ) {
printf("%s::rehash\n", __METHOD__);
$stmt = $this->dbh->prepare('UPDATE users SET pass = ? WHERE user_id = ?');
$stmt->execute([password_hash($password, PASSWORD_DEFAULT), $id]);
}
return $res;
}
}
$auth = new MyAuth(new FakeDB());
for( $i=1; $i<=3; $i++) {
var_dump($auth->authuser($i, 'foo'));
echo PHP_EOL;
}
Output:
MyAuth::authUser::password_verify
MyAuth::authUser::rehash
FakeDB::prepare(["UPDATE users SET pass = ? WHERE user_id = ?"])
FakeDB::execute([["$2y$10$zNjPwqQX\/RxjHiwkeUEzwOpkucNw49yN4jjiRY70viZpAx5x69kv.",1]])
bool(true)
MyAuth::authUser::legacy_hash
MyAuth::authUser::rehash
FakeDB::prepare(["UPDATE users SET pass = ? WHERE user_id = ?"])
FakeDB::execute([["$2y$10$VRTu4pgIkGUvilTDRTXYeOQSEYqe2GjsPoWvDUeYdV2x\/\/StjZYHu",2]])
bool(true)
MyAuth::authUser::password_verify
bool(true)
As a final note, given that you can only re-hash a user's password on login you should consider "sunsetting" insecure legacy hashes to protect your users. By this I mean that after a certain grace period you remove all insecure [eg: bare MD5/SHA/otherwise weak] hashes and have your users rely on your application's password reset mechanisms.