Flow
From the old hash to a signed-in user
The last step doesn't exist yet: old hashes keep their old cost.
1Copy the hash
The import moves each old bcrypt hash over unchanged and clears any PIN
2Report methods only
The report lists how each account signs in, with no secrets in it
3Allow the role
Password login is granted to default, new and existing roles
4Check the password
Swap the version label on a copy, then compare
5Fail the same way
One error for a bad name or a bad password, and a short lock after too many misses
6Upgrade the cost
Not built yet: rehash with a stronger cost after a good login
The first version of my user import gave every account a new PIN and wrote all of them, in plain text, into a CSV.
I'm moving the shift scheduler for the stores off an old PHP app and onto a new backend. My first import made a random PIN for each user, saved a hash of it, marked the account as needing a reset, and put the real PIN in a report file.
Why that file bothered me
It's a key to every account, saved on disk, waiting to be passed around. And the import makes a new PIN each time it runs, so a second run breaks every copy of the list that's already out. People also lose a password they know and get a number they've never seen.
Later the same day the PINs were gone.
What the old hashes already hold
The old app stored passwords as bcrypt hashes. A bcrypt hash keeps its version, its cost and its salt right inside the string, so the new side can check a password with nothing else. Now the import copies the old hash over as it is, and the report just says which login method each account got. Nothing secret in it.
One catch: PHP writes a different version label at the front of the hash than most JavaScript libraries expect. They mean the same thing for normal passwords, so the check swaps the label on a copy before comparing. The saved hash never changes. A test makes a hash, gives it the PHP label, and checks that the right password still works.
Who gets the password page
The login page asks for a username first, then asks the server what to show next. If the account has an old hash, it gets a password page. The account's role also has to allow password login. I gave that permission to the default roles, to new installs, and to roles that already exist, so old and new databases agree.
The same error, mostly
On the password page, a wrong name, a missing hash, a missing permission and a wrong password all get the same error. Too many misses lock the account for a short time. The login page used to promise it stored no passwords, so that line changed.
From the code I can see a few spots where the sameness slips. The first step tells you what kind of login an account uses. A locked account says so. And a wrong username probably comes back faster than a wrong password. I haven't tested any of that.
The old hashes also keep the old cost setting. Nothing upgrades them when someone logs in successfully.