Skip to content

fix(auth): make one email mean one account - #77

Merged
udaycodespace merged 2 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/72-unique-email
Aug 24, 2026
Merged

fix(auth): make one email mean one account#77
udaycodespace merged 2 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/72-unique-email

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Summary

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

Once two rows share an address, loginController authenticates against
whichever one the storage engine returns first, and a reset token written onto
one row can be read from the other.

While adding the constraint I found the quieter half of the same problem:
registration stores addresses lowercased (validateRegistration normalises,
and the schema has lowercase: true) but every lookup afterwards used the raw
request value. Signing in as User@Example.com did not find the account
created as user@example.com.

Related Issue

Closes #72

What changed

  • email is unique: true, and registerController translates the resulting
    E11000 into the same "User already exists" response the pre-check gives.
    Without that the race just turns from a duplicate row into an opaque 500.
  • utils/accountIdentity.js owns normalisation and duplicate-key reading.
    buildEmailFilter deliberately never returns nullfindOne(null) is
    findOne({}) in Mongoose and would hand back an arbitrary account — so an
    unusable value yields { email: "" }, which cannot match a required field.
  • Login, /verify-otp, /forgot-password and /reset-password all resolve
    through that one filter.
  • config/ensureIndexes.js builds declared indexes at startup. Mongoose builds
    them in the background and emits failures on the model; nothing was
    listening, so on a database with duplicates the server would come up looking
    healthy with the constraint silently absent. It now reports the failure once,
    and names the command that fixes it.
  • scripts/dedupeUserEmails.js (npm run db:dedupe-emails) 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. -- --dry-run reports without
    writing.
  • Indexes added on coursePayments (userId, courseId, createdAt) and
    courses (userId, createdAt, enrolled). Both were collection scans.

docs/issue-72-unique-email.md has the reasoning and the order of operations
for an existing deployment.

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).

Test steps

  1. cd backend && npm test
  2. Reproduce the race on a clean database:
    for i in 1 2; do
      curl -s -X POST localhost:5000/api/user/register -H 'Content-Type: application/json' \
        -d '{"name":"Race","email":"race@example.com","password":"password123","type":"student"}' &
    done; wait
    Before: two rows. After: one row and one "User already exists".
  3. Register as user@example.com, verify, then sign in as User@Example.com.
    Before: "User not found". After: signed in.
  4. On a database that already has duplicates: npm run db:dedupe-emails -- --dry-run, then without the flag, then restart.

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 non-string email (an object posted in place of a string) used to reach
findOne and could match nothing predictable; it now produces an unmatchable
filter. duplicateKeyFields handles all three shapes the driver uses —
keyPattern, keyValue, and older versions that only name the index in the
message.

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

Deploy order matters. On a database that already holds duplicates the index
build will fail until db:dedupe-emails has run. That is not silent — startup
prints the collision and the command — but the constraint will not exist until
the duplicates are gone.

The dedupe keeps the oldest row when two are equally verified, because that
is the id enrolments and payments already reference. courseModel.userId is a
String while every other reference is an ObjectId, so authored courses get
their own pass in the script; matching on the ObjectId there silently finds
nothing.

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
One conflict, in the require block of userControllers.js. 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 the resolution keeps only this
branch's own accountIdentity require rather than reinstating the three.
@udaycodespace udaycodespace added bug Something broken or not working as described and removed backend configuration fullstack database tests labels Aug 24, 2026
@udaycodespace

Copy link
Copy Markdown
Owner

@MOHITKOURAV01 Looks good. The unique email handling, normalization, duplicate cleanup, and index changes are properly addressed.
LGTM. Approved.

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

Labels

bug Something broken or not working as described ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points ECSoC26 Required label for a PR to be eligible for Sentinel scoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Two accounts can share one email address — users.email has no unique index

2 participants