feat(auth): hash passwords with password_hash instead of double md5
Passwords were stored as md5(md5($password)): unsalted, and fast enough that a
leaked users table is a list of passwords rather than a list of hashes — two
accounts with the same password even had the same value in the column.
They are now hashed with password_hash(), bcrypt by default and argon2id where a
site configures it, with a per-password salt and a cost that can be raised later.
Nobody is asked to reset anything. A stored value in the old scheme is still
accepted, and the first successful sign-in replaces it with a current hash — the
only moment the password exists in the clear is the only moment this can be done.
Raising the cost later reaches existing accounts the same way.
The column is out of the model's fillable list and into its hidden one: a handler
filling a model from a form can no longer set a password however the form was
crafted, and a user that ends up in a JSON response does not carry the hash.
An empty column is refused outright rather than handed to password_verify(),
which matters for the accounts that will have no password at all.
leaked users table is a list of passwords rather than a list of hashes — two
accounts with the same password even had the same value in the column.
They are now hashed with password_hash(), bcrypt by default and argon2id where a
site configures it, with a per-password salt and a cost that can be raised later.
Nobody is asked to reset anything. A stored value in the old scheme is still
accepted, and the first successful sign-in replaces it with a current hash — the
only moment the password exists in the clear is the only moment this can be done.
Raising the cost later reaches existing accounts the same way.
The column is out of the model's fillable list and into its hidden one: a handler
filling a model from a form can no longer set a password however the form was
crafted, and a user that ends up in a JSON response does not carry the hash.
An empty column is refused outright rather than handed to password_verify(),
which matters for the accounts that will have no password at all.