fix(auth): make password recovery links unguessable and single-use
The recovery code was md5(random_int(1000, 9999)) — nine thousand possible
values, stored in the clear in users.rest_code. Anyone who knew an account id
could walk the whole space in seconds and take the account over, and a database
dump handed out working links directly.
Recovery links are now 32 random bytes, kept only as a SHA-256 digest in the new
password_reset_tokens table. The identifier still in the URL is a convenience:
the token decides whose account it is, and a mismatch is rejected.
Checking the link and spending it are separate steps, because the flow has two:
opening the form only asks whether the link is still good, submitting it is what
consumes the token. So an opened-but-abandoned link keeps working while a
submitted one is dead, and a resubmitted form is a no-op instead of a second
password reset.
Asking for a new link drops the outstanding one, so only the newest letter works
instead of every letter staying valid for its hour. The one-per-day limit now
counts letters sent rather than links followed: spending a token no longer opens
the door to asking for another one straight away.
The table definition lives in AuthSchema, which the installer, the new
auth:upgrade-schema command and the tests all call — a copy in any of them would
drift and the drift would only show on somebody else's site. users.rest_code and
users.rest_time are left in place for now; they are dropped with the rest of the
legacy columns.
Recovery links already in flight stop working when this is deployed.
values, stored in the clear in users.rest_code. Anyone who knew an account id
could walk the whole space in seconds and take the account over, and a database
dump handed out working links directly.
Recovery links are now 32 random bytes, kept only as a SHA-256 digest in the new
password_reset_tokens table. The identifier still in the URL is a convenience:
the token decides whose account it is, and a mismatch is rejected.
Checking the link and spending it are separate steps, because the flow has two:
opening the form only asks whether the link is still good, submitting it is what
consumes the token. So an opened-but-abandoned link keeps working while a
submitted one is dead, and a resubmitted form is a no-op instead of a second
password reset.
Asking for a new link drops the outstanding one, so only the newest letter works
instead of every letter staying valid for its hour. The one-per-day limit now
counts letters sent rather than links followed: spending a token no longer opens
the door to asking for another one straight away.
The table definition lives in AuthSchema, which the installer, the new
auth:upgrade-schema command and the tests all call — a copy in any of them would
drift and the drift would only show on somebody else's site. users.rest_code and
users.rest_time are left in place for now; they are dropped with the rest of the
legacy columns.
Recovery links already in flight stop working when this is deployed.