Flow
Cases the tests must cover
1Hash changed
Step two sees a new password hash: no session, audit entry or token.
2Hash removed
Step two sees no password hash: same refusal.
3No proof
Step two is called with no proof: it refuses before any permission lookup.
4Permission revoked
The second permission check says no password login: refusal.
5Email changed or cleared
Email code login with an old address: refusal.
6Admin email edit
Same address after normalizing deletes nothing. A failed delete stops the edit.
Already-reset passwords could still get a session from my login code. It checked that the account still existed. It never checked that the password was the same one.
Two steps with a gap
Login in my scheduling app has two steps. Step one locks the user, checks the password and saves the result. Step two locks the user again and creates the session. I'd guess the split is there because several login types share step two.
The trouble was the gap. If an admin reset the password, removed it, or changed the email between the two steps, step two still saw an active user and logged the person in. Email code login had the same gap.
PIN login was already safe. It passed a small proof of what it had checked, and step two checked that proof again. Password and email login didn't.
The gap is tiny. I still want it shut. People reset passwords when they fear a leak, and that's when an attacker keeps trying.
Passing the proof along
Now step one hands over the exact stored password hash it matched, or the email it verified. A hash is the scrambled copy of a password that the app keeps. Step two locks the user and compares:
if (!proof || fresh.hashOnFile !== proof.hash) throw new LoginRefused();
No proof means no session. Step two also checks, under the same lock, that the user is still allowed to log in with a password. Every failure shows the same plain login error, so nobody learns that a reset was in progress.
Sign-up recovery too
A new sign-up can recover its owner account for a while, based on a verified email. If an admin changed that email, the old sign-up still worked. Now changing the email deletes those sign-ups, but only when the address really changed. If that delete fails, the whole edit stops. Recovery also checks the current email against the verified one.
Every simulated change ends with no session
The tests fake the database. They cover a changed or removed password, a revoked permission, a missing proof, and a changed or cleared email. Each one must end with no session. Next I want a test that forces a reset between the two steps on a real database.