Skip to content

feat(auth): deliver verification codes via email with security hardening - #39

Merged
Cho-Geer merged 8 commits into
developfrom
feat/email-verification-code
Sep 18, 2026
Merged

Cho-Geer merged 8 commits into
developfrom
feat/email-verification-code

Conversation

@Cho-Geer

Copy link
Copy Markdown
Owner

Summary

  • Verification codes are now delivered by email instead of plaintext logs (replaces SMS TODO path at auth.service.ts).
  • REGISTER pre-checks: duplicate phone and duplicate email (case-insensitive via findFirst + mode:'insensitive') are rejected before any code is generated, emailed, or written to Redis (409 EMAIL_EXISTS, no side effects).
  • Security hardening: Redis keys scoped by type (verification_code:{type}:{phone}), attempt counter with resend reset, per-destination 60s cooldown, IP throttle 5/60s on the send endpoint (@nestjs/throttler scoped to AuthModule, app.module untouched), crypto.randomInt generation, plaintext code logs removed (both call sites), dev-only log masked, email subject contains no code, SMTP failure logs sanitized.
  • LOGIN resolves the recipient from the DB via new internal UsersService.findUserEmailByPhoneNumber (sha256 phoneHash reuse); missing email returns explicit error. REGISTER binds the account email to the verified destination (verification_code_email:{type}:{phone}, checked before code validation, no consumption on mismatch).

Test evidence

  • Unit: 312 passed (19 suites) — includes dual-direction case-variant tests + mutation checks
  • e2e: 55 passed (8 suites, TestContainers PostgreSQL + MailHog real SMTP: 409 path asserts no MailHog message & no Redis writes; 429 fire verified)
  • npm run build / typecheck / lint:check → 0 errors

Notes

  • Reviewed through multi-agent flow (general-purpose implementation + high-precision review, 2 iterations to final PASS).
  • Commit 7095396 intentionally points seed emails to real mailboxes for manual testing (reviewer-noticed; author's own change).
  • Production readiness (separate follow-up): F3 MAIL_* env in booking-deploy, SPF/DKIM, real SMTP delivery check, NULL-email user migration.
  • Non-blocking follow-ups: error.stack in EmailService catch, NODE_ENV-gated dev log flag, @Throttle on login/register, trust proxy doc, sequence diagrams, LOWER(email) functional index if users table grows.

TraeAI added 8 commits September 17, 2026 23:51
Replace the plaintext dev log code delivery with real email delivery and add the
security hardening agreed in review. Existing EmailService methods keep their
swallow-on-failure contract; a new throwing sendVerificationCode is added.

- EmailService.sendVerificationCode(to, code, expiresMinutes): renders the new
  Chinese verification-code template, logs recipients via maskEmail, and throws
  on failure so the caller can abort the flow
- AuthService.sendVerificationCode(phoneNumber, type, email?): REGISTER uses the
  requested email, LOGIN resolves the bound email from DB (never the request),
  missing recipient email raises RecipientEmailMissingException
- Throw order: generate -> send email (abort on failure) -> save to Redis ->
  set per-recipient cooldown; SMTP failures map to ExternalServiceException
- Redis keys are type scoped (verification_code:{type}:{phone}) with the stored
  value kept as the raw 6-digit string for external e2e compatibility
- Wrong-code attempts are counted with a get/set read-modify-write, invalidating
  the code at the limit; missing and mismatching codes return the same message
- Codes are now generated with crypto.randomInt; the plaintext send log and the
  SMS TODO are removed, the dev log masks the phone number
- UsersService.findUserEmailByPhoneNumber returns the unmasked email
- SendVerificationCodeDto.email is required for REGISTER (ValidateIf + IsEmail)
- AuthModule imports EmailModule and ThrottlerModule.forRoot; only the send
  endpoint is guarded (ThrottlerGuard + @Throttle 5/60s), no global APP_GUARD
- New RecipientEmailMissingException and VerificationCodeRateLimitException(429)
- auth.service.spec: email dispatch per scenario (register email / login bound email),
  missing-recipient errors, SMTP failure abort, cooldown rejection, type-scoped
  keys, attempt counter and attempt-limit invalidation, indistinguishable messages
- email.service.spec: success (subject/context) and rejects-on-failure cases for
  sendVerificationCode (existing 3 describes unchanged)
- users.service.spec: findUserEmailByPhoneNumber (found / user missing / no email)
- auth.controller.spec: import ThrottlerModule so the guarded endpoint resolves
- test/auth.e2e-spec: fixture gains an email, EmailService is stubbed, login send
  asserts delivery to the bound email plus new REGISTER cases (missing email 400 /
  requested email delivery)
- test/users.e2e-spec: fixtures gain emails, EmailService stubbed, login helper
  seeds the type-scoped code key directly (IP throttle 5/60s and the 60s
  recipient cooldown make repeated send calls impossible in one spec file)
- test/email.e2e-spec: MailHog assertion for the verification-code email
- api-contract.md / api-contract.ja.md: send-verification-code gains the email
  parameter (required for register, forbidden for login), documents the 5-minute
  email delivery, the 5 requests / 60 s endpoint throttle plus 60 s per-recipient
  cooldown (429) and the RECIPIENT_EMAIL_MISSING / EXTERNAL_SERVICE_ERROR errors
- docs/redis-usage-and-schema.md: key inventory updated to 5 kinds / 6 patterns
  with the type-scoped verification_code keys (code / attempts / cooldown), the
  stored value kept as the raw 6-digit string, email wording replacing SMS,
  refreshed code line references and verification greps

Note: the mirrored docs outside this repository (../docs/redis-usage-and-schema.md
and ../docs/business-flow/auth/01,02,06) received the same corrections but are not
tracked by this repository.
… issued address

Review follow-up for the email verification code flow (P1-1 / P1-2).

- saveVerificationCode now deletes the wrong-code attempt counter, so a
  previously failing code can no longer eat into the fresh code's attempt budget
  (unit test: 4 wrong attempts -> resend -> the new code is still accepted, and
  the counter restarts at 1; verified by mutation check)
- sendVerificationCode records the (trimmed, lower-cased) recipient address as
  verification_code_email:{type}:{phone} with the same 5-minute TTL
- register() compares registerDto.email against that recorded address BEFORE
  validateVerificationCode, so a mismatch (or missing email / no issued address)
  rejects with 400 验证码与邮箱不匹配,请重新获取 without consuming the code or
  the attempt counter (one message for all causes, no extra information leak)
- SMTP failure logging no longer prints the raw error.message (may contain RCPT
  addresses): fixed text + maskEmail recipient + error.code (<= 40 chars) only
- auth.e2e-spec: register describe added (success / mismatch with untouched code
  and no attempt counter / case-normalized match); the send test now asserts the
  recorded issued address
Review follow-up (P2-2 / P2-4) plus the matching contract documentation.

- the subject becomes 【Booking System】邮箱验证码 so the code can no longer leak
  through inbox previews, notifications or subject-only logs; the code stays in
  the body context unchanged
- email.service.spec asserts the subject does not contain the code and still
  checks the body context; the MailHog e2e asserts Subject lacks the code while
  Body contains it
- docs/api-contract.md / .ja.md: register notes gain the email-must-match-issued
  -address rule (normalization, 400 response, no code/attempt consumption), the
  send endpoint notes now say login email is ignored (not "must be omitted"),
  mention the recorded issued address, the code-free subject and the attempt
  counter reset on resend
- docs/redis-usage-and-schema.md: key inventory extended with
  verification_code_email (6 kinds / 7 patterns), refreshed line references and
  verification greps
Reject a REGISTER verification-code request when the submitted email already
belongs to an existing account, before any code is generated, mailed or stored
in Redis. Previously the duplicate was only detected at register time, so the
UI advanced to code entry and failed with 409 only after submission.

- auth.service: pre-check via usersService.findUserByEmail with the same
  normalization (trim + lowercase) used for the register email binding and
  rethrow EmailExistsException as-is (no DatabaseException wrapping)
- auth.service.spec: duplicate/email-normalization/pre-check-passed cases plus
  no-send/no-cache-write assertions, LOGIN unaffected, and 429-before-409
  cooldown precedence
- auth.e2e-spec: MailHog-backed test proving 409 EMAIL_EXISTS adds no message
  to the inbox and writes nothing to Redis
- docs: document 409 EMAIL_EXISTS (pre-send check) and the 429 precedence
The pre-send duplicate check normalized the address to lowercase but searched
with findUserByEmail, whose findUnique exact match misses a stored raw address
that differs only in case (DB emails are stored as entered and the PostgreSQL
unique constraint is case-sensitive), so a duplicate account could still be
created. Documented behaviour already promised a case-insensitive comparison.

- users.service: add findUserByEmailInsensitive using
  findFirst({ where: { email: { equals, mode: 'insensitive' } } }); the exact
  match findUserByEmail keeps its contract and is no longer used by the
  register pre-check
- auth.service: register pre-check calls findUserByEmailInsensitive
- auth.service.spec: cover both case directions (mixed-case stored/small input
  and lowercase stored/upper input) with no-send/no-cache-write assertions
- users.service.spec: pin the insensitive findFirst predicate
- auth.e2e-spec: create a mixed-case fixture user and assert a lowercase
  register request is rejected with 409 before any mail is delivered
- docs: state that the pre-send check is case-insensitive while the DB unique
  constraint is case-sensitive
@Cho-Geer
Cho-Geer merged commit d8e3522 into develop Sep 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant