Skip to content

fix(auth): hash one-time codes and stop confirming which emails exist - #100

Merged
udaycodespace merged 4 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/95-otp-credential-storage
Aug 28, 2026
Merged

fix(auth): hash one-time codes and stop confirming which emails exist#100
udaycodespace merged 4 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/95-otp-credential-storage

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Summary

Every pending verification and password-reset code was stored in the users collection as the six digits the endpoint accepts, and /verify-otp and /reset-password answered differently for an unknown address than for a wrong code. This hashes the credentials, makes every failure indistinguishable, and bounds guessing per account.

Related Issue

Closes #95

What changed?

backend/utils/otpCredentials.js (new)

function does
generateCode() six digits from crypto.randomInt, not Math.random()
issueCredential() { code, hash, expiresAt, attempts: 0 } — the plaintext is returned once for the email and never persisted
verifyCredential(stored, candidate) { status, attempts, shouldClear }
isWellFormedCode() shape check before any comparison
burnComparison() one bcrypt comparison against a decoy hash
isFailure(status) everything except OK — fails closed for a status added later

verifyCredential returns OK, INVALID, EXPIRED, LOCKED or MISSING. The callers answer all four failures identically; the distinction exists for the code path, never for the client.

shouldClear is the invalidation rule in one place: a credential dies when it is used, when it expires, and when it reaches MAX_ATTEMPTS. That last one matters — a locked-but-live code is a code somebody can keep working on. A missing, null, NaN or non-numeric expiresAt counts as expired rather than valid.

burnComparison is the part that is easy to omit. Uniform responses are only uniform if they take the same time; without a decoy comparison on the "no such account" path, the absence of a bcrypt call becomes the new oracle. The decoy hash is computed once at module load.

Schemaotp and resetToken hold a bcrypt hash. otpAttempts and resetTokenAttempts are new, select: false, and deliberately carry no default: an absent counter means no pending code, and $unset has to mean gone. Both are added to toJSON and to adminController.SENSITIVE_USER_FIELDS, and the admin reset-password $unset clears the counter with the token.

Controllers

  • register stores credential.hash; the plaintext reaches the mailbox and nothing else.
  • verify-otp answers 400 "Invalid or expired OTP" for an unknown address, a wrong code, an expired code, a locked account and a missing code — same status, same body, same work. A verified account has any leftover code cleared. On success, isVerified and the $unset are one atomic update.
  • forgot-password stores the hash and resets the attempt counter, so an account that hit the limit can recover by asking for a new code. Its uniform response is unchanged.
  • reset-password answers 400 for an unknown address where it used to answer 404 "User not found", and on success clears the reset credential and any pending verification code. It still sets isVerified — that was already the behaviour and is now stated in a comment: holding the code proves control of the mailbox.
  • The 500 path no longer echoes error.message to the client.

Type

  • Bug fix
  • New feature
  • Refactor
  • Docs only
  • Tests
  • Config / workflow
  • Security
  • Breaking change

Areas touched

  • Frontend
  • Backend
  • Database
  • Docs
  • Workflow / GitHub Actions
  • Config / environment

Migration: none needed. A plaintext code left over from before this ships simply fails bcrypt.compare, the user is told the code is invalid — which it now is — and requests a new one. This fails closed, which is the right direction for a credential change.

Testing

  • Tested locally
  • Build passes
  • Lint passes
  • Tests added or updated
  • Docs only, no runtime testing needed

npm test in backend/257 passing, up from 234 on main. 23 new tests, including integration tests against mongodb-memory-server.

npm run lint is a frontend-only script and this PR touches no frontend files; it has never passed on main (69 problems, almost all pre-existing no-unused-vars on React imports).

Test steps

  1. Register a@example.com.
  2. db.users.findOne({email:"a@example.com"}, {otp:1}) → a $2a$10$… hash. On main this is the six digits that were just emailed.
  3. curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:5000/api/user/verify-otp -H 'Content-Type: application/json' -d '{"email":"nobody@example.com","otp":"000000"}'400. On main, 404.
  4. The same call for a@example.com400, with an identical body. The oracle is gone.
  5. Send five wrong codes, then the correct one → still refused, and otp is gone from the document.
  6. POST /api/user/forgot-password → a fresh hash and resetTokenAttempts back to 0.

Screenshots

  • Not needed
  • Added below

Edge cases checked

  • Empty or missing data
  • Loading / slow response
  • API failure / server error
  • Rate limit / throttling
  • Invalid or unexpected input
  • Permission / access denied
  • Partial or inconsistent data
  • Mobile / small screen behavior
  • Other

Other edge case details

  • A malformed candidate — wrong length, letters, a number, null, an object — costs an attempt and never matches.
  • A missing, null, NaN or non-numeric expiry is treated as expired, never as valid.
  • A credential already at the limit is refused even for the correct code.
  • A code left behind on an account that is already verified is cleared rather than left live.
  • Replaying a spent reset code leaves the first new password in place.
  • An account locked out of a reset can recover by requesting a new code.

Checklist

  • Read CONTRIBUTING.md
  • Linked the issue
  • Assigned before starting or approved by maintainer
  • Changes are focused on one issue
  • No debug logs or unused code
  • Documentation updated if needed
  • No new warnings or console errors
  • Changes are meaningful, not trivial

Notes

docs/issue-95-otp-credential-storage.md has the full write-up, including why the decoy comparison is load-bearing.

This is complementary to #63, not a substitute for it. MAX_ATTEMPTS bounds guesses against one account regardless of how many source addresses they come from; the limiter in #63 bounds one source regardless of how many accounts it targets. Both are wanted.

Registration and password reset generated a six-digit code with Math.random()
and wrote it to the user document verbatim. `select: false` and the admin
projection from udaycodespace#54 control what a query returns; neither changes what is on
disk. Anything with read access to the collection — a backup, a replica, a
mongodump in a CI artefact — carried a working credential for every account
with a pending code, usable without ever seeing the mailbox.

verify-otp answered 404 "User not found" for an unknown address and 400 for a
wrong code, and reset-password did the same. Two status codes on an
unauthenticated endpoint is an enumeration oracle: one request confirms or
denies any address. forgotPasswordController in the same file already answered
uniformly, so the project had already decided this matters.

utils/otpCredentials issues codes from crypto.randomInt, stores only the bcrypt
hash, compares through bcrypt, and returns one of OK, INVALID, EXPIRED, LOCKED
or MISSING with an attempt count. The callers answer all four failures with the
same status and the same body, after a decoy bcrypt comparison on the paths
where there is nothing to compare — without that the absence of a comparison is
the new oracle.

A credential now dies when it is used, when it expires, when a new one
supersedes it, and when it reaches five failed attempts; a locked-but-live code
is a code somebody can keep working on. Requesting a new code resets the
counter, so a locked-out account can recover. A verified account has any
leftover code cleared, and a password reset clears the pending verification
code with it.

otpAttempts and resetTokenAttempts are select: false and carry no default, so
an absent counter means no pending code and $unset means gone. Both are added
to toJSON and to SENSITIVE_USER_FIELDS.

No migration: a plaintext code left from before fails bcrypt.compare and the
user asks for a new one, which is the right direction for a credential change.

Closes udaycodespace#95
@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

Overlap with #78, worth deciding before either lands

Both merge cleanly into main as they stand, but they conflict with each other on backend/controllers/userControllers.js and backend/schemas/userModel.js, and the overlap is substantive rather than textual:

Whichever lands second has to take on the other's shape. Concretely:

I am happy to rebase whichever one you prefer to take second — just say which order you want and I will push the resolution.

My suggestion is this PR first: #78's resend is a small handler and adapting it to issueCredential() is a few lines, whereas adapting this PR to a resend endpoint that does not exist yet is not possible to review properly.

@udaycodespace
udaycodespace self-requested a review August 24, 2026 15:35
@udaycodespace udaycodespace added ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP and removed documentation backend fullstack database tests labels Aug 24, 2026
Three conflicts in userControllers.js, all of them the same shape: main
normalised the account lookups through buildEmailFilter for udaycodespace#72 while this
branch was changing what those lookups select.

Both sides are wanted, so both are kept. verify-otp and reset-password now
resolve the account through buildEmailFilter and select the attempt counters
this branch adds. The import block keeps otpCredentials and accountIdentity.
Two independent limits guard /verify-otp and both default to five: the
per-account failure throttle from udaycodespace#63, which answers 429 and locks the address,
and the per-credential attempt counter this branch adds, which answers 400 and
burns the code. The throttle is middleware, so at equal limits it fires first
and the counter is never reached.

Both are wanted in production, and the outcome is the same either way — the code
stops working. But this test's subject is the counter, so it gives the throttle
headroom rather than loosening the assertion to "refused, somehow". A second
test pins the default interaction, so the ordering is stated rather than implied.
Main gained the resend route (udaycodespace#73) while this branch was open, and the two
changes land on the same three functions.

The conflicts were not competing implementations, they were two halves
that had to be put together. Main routes every code through
issueVerificationOtp — registration and /resend-otp both — with a cooldown
derived from otpLastSentAt. This branch replaced plaintext storage with a
bcrypt hash and an attempt counter.

So the hashing moved inside issueVerificationOtp rather than staying
inline in registerController. It is now the one place a verification code
is issued, which is what closes registration and the resend route in the
same edit: it stores credential.hash, resets otpAttempts, keeps
otpLastSentAt, and mails the plaintext it never writes down.

userModel keeps both new fields — otpAttempts from here, otpLastSentAt
from main — and deletes both in toJSON.

verifyOtpController keeps the hashed comparison. Main's side compared a
plaintext column that no longer exists, so there was nothing to preserve
there. What udaycodespace#73 added on top of it is kept: `canResend` still rides on the
failure response so VerifyEmailPanel keeps offering the button.

It is constant rather than derived, which is a deliberate change to udaycodespace#73's
version. Deriving it from user.otpLastSentAt would put the enumeration
oracle straight back — an unknown address has no send time, so it would
answer differently from a real one on cooldown, and this response is
uniform on purpose. The cooldown is still enforced and still reported, by
/resend-otp, which owns it and answers 429 with the exact wait;
VerifyEmailPanel already reads retryAfterSeconds off that response. The
existing test asserting the two responses are byte-identical passes.

One test updated: otp-resend asserted user.otp matched /^\d{6}$/, which
was the defect. It now asserts the mail carries the code, the database
carries a bcrypt hash of it, and the two verify against each other.

Verified end to end: register mails 898200 and stores $2a$10$..., a wrong
code and an unknown address answer identically, the right code verifies,
and login succeeds.
@udaycodespace
udaycodespace merged commit 0d21ace into udaycodespace:main Aug 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security]: OTPs and password-reset codes are stored in plain text, and /verify-otp discloses which emails have accounts

2 participants