Skip to content

feat(auth): let an unverified account get a new OTP - #78

Merged
udaycodespace merged 4 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/73-otp-resend
Aug 27, 2026
Merged

feat(auth): let an unverified account get a new OTP#78
udaycodespace merged 4 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/73-otp-resend

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Summary

The verification code expires after 10 minutes and nothing anywhere issued a
second one. Registering again answered "User already exists", logging in
answered "Email is not verified", and there was no resend route — so a missed
code burned the address until a maintainer deleted the row by hand.

The UI made it easy to land there. Register.jsx held the verify step in
useState, so a refresh — or reading the code in another tab and coming back —
threw away both showOtpInput and data.email. The code sat in the inbox with
nothing left that knew which address it belonged to.

Related Issue

Closes #73

What changed

Backend

  • POST /api/user/resend-otp, with a one-minute-per-address cooldown derived
    from a new otpLastSentAt field on the user rather than from anything the
    client sends. An unknown address, an already-verified account and a
    successful send all return an identical body — a resend route takes an
    address from an anonymous caller, and one that says "no such account" is an
    enumeration oracle.
  • registerController stops collapsing two different cases. A verified
    account still answers "User already exists". An unverified row is a
    registration nobody completed, so nobody owns the address yet: the details
    are taken from the new attempt (the user may be retrying because they
    mistyped something) and a fresh code goes out.
  • utils/otpCodes.js holds the length, lifetime, cooldown and mail copy in one
    place so registration and resend cannot drift apart. generateOtp moves off
    Math.floor(100000 + Math.random() * 900000)Math.random is not a CSPRNG
    and this value is the only thing guarding the account. Same range, so the
    code is still exactly six digits.
  • verifyOtpController clears otpLastSentAt, trims the submitted code, and
    returns canResend so the UI knows whether offering the button will do
    anything or only answer 429.

Frontend

  • VerifyEmailPanel is shared by Register and Login and keeps the pending
    address in sessionStorage, which is what makes a refresh survivable. The
    value is an address the user just typed into a form on the same page.
  • Login handles notVerified: true — the API has always sent it and nothing
    read it — and offers the verify step instead of a dead end.
  • Register swaps its four alert() calls for the Toast Replace native browser alerts with a Toast/notification component in Login #36 introduced,
    disables submit while in flight, checks the 6-character password rule before
    sending, and stops labelling its password field current-password.
  • The resend button counts down and seeds that countdown from the server's
    retryAfterSeconds rather than guessing.

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

Testing

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

npm test in backend/: 143 passing (128 before, 15 added). npm run build
in frontend/ passes.

Test steps

  1. Register, never enter the code.
  2. POST /api/user/resend-otp with that address → 200 and a new code.
  3. Immediately again → 429 with retryAfterSeconds.
  4. Same call for an address nobody registered → byte-identical 200, and no mail
    is sent.
  5. In the UI: register, press F5 on the verify screen. The step is still there
    with the address intact.
  6. Try to log in with an unverified account → the verify panel appears instead
    of just an error message.

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

secondsUntilResend treats a timestamp in the future — clock skew between app
servers — as one cooldown rather than a lockout until that time passes. A
missing address is a 400 before any lookup happens. A database failure is a 500
that does not put the driver's message in the response body. sessionStorage
being refused (private browsing) degrades to the previous in-memory behaviour
rather than throwing.

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

This is the missing recovery path, not throttling — #63 covers rate limiting on
the auth endpoints generally. The cooldown here exists because a resend route
without one is a free mail relay pointed at any address an attacker picks, and
it belongs on the route regardless of what #63 lands.

The re-registration behaviour is the part worth a second opinion: taking the
name/password/type from the new attempt is deliberate, on the grounds that
nobody has proven ownership of that address yet, so there is nothing to
protect. Happy to make it keep the original details if you would rather.

docs/issue-73-otp-resend.md has the full reasoning.

users.email had no unique index, so the findOne check in registerController
was the only thing between two concurrent registrations and two accounts on
the same address. It is a read followed by a write, so both requests win.

Adds the constraint and everything it needs around it:

- email is unique on the schema, and registerController translates the
  resulting E11000 into the same "User already exists" response the pre-check
  returns, so the race does not just turn into a 500.
- accountIdentity.js normalises addresses in one place. Registration stored
  them lowercased while every lookup afterwards used the raw request value,
  so signing in as User@Example.com missed an account created as
  user@example.com. Login, verify-otp, forgot-password and reset-password now
  all resolve through the same filter.
- ensureIndexes() builds declared indexes at startup instead of leaving
  Mongoose's background builder to fail silently, and names the fix in the
  error when duplicates are blocking the build.
- scripts/dedupeUserEmails.js merges pre-existing duplicates: keeps the
  verified/oldest row, re-points enrolments, payments, reviews, bookmarks,
  logs and authored courses at it, and drops rows that would violate a
  compound unique key. Supports --dry-run.

Also indexes coursePayments by userId/courseId/createdAt and courses by
userId/createdAt/enrolled; both were collection scans on every request.

Closes udaycodespace#72
The verification code expires after ten minutes and nothing issued a second
one. Registering again answered "User already exists", logging in answered
"Email is not verified", and there was no resend route, so a missed code
burned the address until a maintainer deleted the row by hand.

Backend:

- POST /api/user/resend-otp, with a one-minute-per-address cooldown derived
  from a new otpLastSentAt field rather than from anything the client sends.
  An unknown address, an already-verified account and a successful send all
  return the same body, so the route is not an enumeration oracle.
- registerController stops collapsing two different cases. A verified account
  still answers "User already exists"; an unverified row is a registration
  nobody completed, so the details are taken from the new attempt and a fresh
  code goes out.
- otpCodes.js holds the length, lifetime, cooldown and mail copy so
  registration and resend cannot drift apart. generateOtp moves off
  Math.random, which is not a CSPRNG and is the only thing guarding the
  account.
- verifyOtpController clears otpLastSentAt and reports canResend so the UI can
  tell whether the button will do anything.

Frontend:

- VerifyEmailPanel is shared by Register and Login and keeps the pending
  address in sessionStorage. Refreshing the verify step used to discard both
  the step and the address, leaving a valid code in the inbox with nothing to
  enter it against.
- Login handles notVerified, which the API has always sent and nothing read.
- Register swaps its alert() calls for the existing Toast, disables submit
  while in flight, and stops labelling its password field current-password.

Closes udaycodespace#73
@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

Heads up on merge order: this branch and #77 both rewrite registerController, so they conflicted on backend/controllers/userControllers.js. Rather than leave that for whoever merges second, I rebased this branch on top of #77 and resolved it here.

The resolution keeps both fixes rather than picking one:

  • the existence check uses fix(auth): make one email mean one account #77's normalised buildEmailFilter(value.email) and selects +otp +otpExpiry +otpLastSentAt, because an unverified row is re-issued a code below rather than turned away;
  • fix(auth): make one email mean one account #77's E11000 guard moves from newUser.save() onto issueVerificationOtp(), since that helper is what writes the document now. It saves before it mails, so a rejected insert still sends nothing.

So this PR currently shows #77's commits in its diff. Merge #77 first and this one collapses to just its own changes. If you would rather they stayed independent, say so and I will unstack it — it will just mean resolving the same conflict at merge time.

Verified: npm test in backend/ is 158 passing on this branch, and merging #77#78#79#80#81 in sequence is clean with 190 backend tests passing on the combined result.

@udaycodespace
udaycodespace self-requested a review August 17, 2026 09:48
@udaycodespace udaycodespace added ECSoC26 Required label for a PR to be eligible for Sentinel scoring in review PR is up and waiting on maintainer review redo Reviewed — needs changes before it can be merged and removed in review PR is up and waiting on maintainer review frontend documentation backend configuration fullstack database tests labels Aug 17, 2026
Three conflicts, all unions:

- userControllers.js require block: this branch carried a require for
  buildPaymentSummary, formatPaymentMessage and isFreeCourse, which main has
  since moved into enrollmentController (udaycodespace#62) and removed from here. Nothing in
  this file uses them any more, so only the accountIdentity require is kept.
- Register.jsx imports: main added ROLES/roleLabel (udaycodespace#84), this branch added
  Toast and VerifyEmailPanel. Both.
- Register.jsx handleSelect: main rewrote it so the stored role and the label
  on the toggle stop being the same string; this branch added the toast helpers
  and the pending-email effect immediately above it. Main's implementation is
  kept, with this branch's additions in front of it.
@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

Overlap with #100, 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 #100 first, then this one: adapting this resend handler to issueCredential() is a few lines, whereas adapting #100 to an endpoint that does not exist yet is not reviewable.

@udaycodespace udaycodespace added good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP and removed redo Reviewed — needs changes before it can be merged frontend documentation backend configuration fullstack database tests labels Aug 24, 2026
Four conflicts, all from udaycodespace#63's credential throttling and udaycodespace#72's email
normalisation landing on main while this branch reworked the same two files.

registerController: this branch's version supersedes main's on both hunks. Its
verified/unverified split replaces the unconditional "User already exists", and
issueVerificationOtp saves the document and mails the code in one helper — it
already carries main's isDuplicateOn catch, in the right place, because the save
now happens inside it.

userRoutes: main's rate limiter and failure throttle are kept on verify-otp,
forgot-password and reset-password, and /resend-otp is given the same treatment.
It is rate limited because it sends mail, and has no failure throttle for the
reason /forgot-password has none — it answers the same way for known and unknown
addresses, so there is no failure to count. The per-address cooldown stays in the
controller so it applies however the code is requested.
@udaycodespace

Copy link
Copy Markdown
Owner

@MOHITKOURAV01 Everything looks good from my side. Merge conflicts are resolved, and the changes are aligned with the intended OTP resend flow. The updated auth handling and edge cases look good as well.

Approved to merge.

@udaycodespace
udaycodespace merged commit 6b9ca6d into udaycodespace:main Aug 27, 2026
2 of 10 checks passed
@ecsoc-sentinel ecsoc-sentinel Bot added the ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points 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.

[Bug]: An unverified account is a dead end — the OTP expires after 10 minutes and there is no way to get another one

2 participants