Skip to content

Add email verification on registration#302

Open
hamza-56 wants to merge 27 commits intomainfrom
feat/email-verification
Open

Add email verification on registration#302
hamza-56 wants to merge 27 commits intomainfrom
feat/email-verification

Conversation

@hamza-56
Copy link
Copy Markdown
Contributor

@hamza-56 hamza-56 commented May 4, 2026

What

Adds email confirmation on account registration. New email/password signups create unverified accounts; users must click a tokenized link before they can log in. Existing users are auto-verified via migration backfill, and Google OAuth signups are auto-verified on creation/link. Also enforces password complexity at registration.

Summary

Backend

  • New email_verification_token table; sha256-hashed tokens (raw never persisted), 24h expiry, single-use, prior-token invalidation on resend.
  • EmailVerificationService mints / verifies / resends; send_verification_email composes a multipart text + HTML body (HTML-escaped name to prevent injection).
  • app/core/email.py wraps aiosmtplib — works with Amazon SES SMTP, Mailgun, Gmail, MailHog locally.
  • Endpoints: POST /auth/verify-email (400 invalid/expired/used), POST /auth/verify-email/resend (Redis-cooldowned via SET NX EX, async-context-manager lifecycle).
  • POST /auth/register schedules the email via BackgroundTasks (atomic flush + single commit).
  • POST /auth/login returns 403 {code:"email_not_verified", email} for unverified accounts (only reachable post-password-verification).
  • validate_password_complexity in app/core/security.py enforces ≥8 chars + uppercase + digit + special; runs via UserCreate.field_validator.
  • Email is normalized (strip().lower()) at register and Google OAuth callback to prevent case-sensitive lookup mismatches and duplicate accounts.
  • Alembic migration adds the schema and backfills existing users as verified (cross-DB safe via sa.func.now()).

Frontend

  • New /verify-email page with success / expired / invalid states and a resend form.
  • Login form renders an inline alert + resend button when login returns the unverified 403 (idle / sending / sent / rate-limited).
  • Register form shows "check your email — we've sent a confirmation link to {email}" instead of auto-redirecting; password input shows the complexity rules as inline hint text.

How to Test

  1. make test — 45 new/updated tests cover token lifecycle, all four endpoints, Google OAuth verification, SMTP composition, resend rate limiting, and password complexity.
  2. make frontend.build — clean Next.js build with /verify-email route generated.
  3. End-to-end with MailHog: register with whitelisted email → "check your email" → click link from MailHog → "Email confirmed" → log in → success.
  4. Try logging in unverified → resend CTA → resend → second email → resend immediately → "Please wait before resending" (429).
  5. Submit weak passwords (abc, alllowercase1!, NoDigits!!, NoSpecial1A) → 422 with field-level error; submit Sup3rSecret! → 200.
  6. Sign up via Google OAuth → confirm email_verified=true on the resulting user row.
  7. Apply the migration on a non-empty user table (Postgres + SQLite) → confirm existing rows are marked verified.

Closes #289

@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude finished @hamza-56's task in 3m 49s —— View job


Review: feat(auth): add email verification on registration

  • Check for previously addressed review comments — none found
  • Review backend changes (models, services, API, email, config)
  • Review migration
  • Review frontend changes
  • Review test coverage
  • Security review
  • Post inline comments and summary

Overview

Well-structured feature that adds email verification gating on login. The architecture is sound: SHA-256 hashed tokens, proper invalidation of prior tokens, Redis-based rate limiting, background email sends, Google OAuth auto-verification, and an existing-user backfill migration. Test coverage is thorough (38 new/updated tests) and the frontend handles all verification states cleanly.

Issues Found

🔴 High

# File Issue
1 app/services/email_verification.py:117-123 HTML injection in email template — User-controlled name is interpolated into HTML without escaping. Fix: use html.escape(). See inline comment →

🟡 Medium

# File Issue
2 app/api/v1/auth.py:300 Case-sensitive email mismatch — Resend endpoint lowercases email but registration stores as-is; PostgreSQL = is case-sensitive, so Alice@Example.com won't match alice@example.com. Normalize at registration time. See inline comment →
3 app/api/v1/auth.py:111-122 Two separate commits — User creation and token creation are in different transactions, reducing atomicity. A failure between them leaves a user with no token. See inline comment →
4 app/api/v1/auth.py:287-291 Private attribute accesscache._redis breaks cache service encapsulation. Add a public accessor or use Redis directly. See inline comment →

🔵 Low

# File Issue
5 app/api/v1/auth.py:316-322 Unnecessary DB query — Re-queries user after commit for email/name that are already available. See inline comment →
6 app/api/v1/auth.py:155-159 Email exposed in 403 — Login error leaks the registered email given a known username. Conscious trade-off for UX. See inline comment →
7 frontend/app/verify-email/page.tsx:12 Suspense without fallback — No loading state while client component initializes. See inline comment →

What looks good

  • Token security: SHA-256 hashing (raw never persisted), secrets.token_urlsafe(32), prior-token invalidation on new mint — all solid.
  • Migration: Clean schema addition with sa.func.now() for cross-DB backfill. Proper downgrade. Indexes on user_id and token_hash.
  • Rate limiting: Redis SET NX EX is the right primitive for cooldown windows. Silent 202 for unknown/verified emails prevents user enumeration.
  • Test coverage: Service-layer unit tests, endpoint integration tests, Google OAuth verification tests, SMTP composition tests, and rate-limit tests. The conftest.py autouse stub pattern is clean.
  • Frontend: Good state machines for resend CTA (idle/sending/sent/rate_limited/error). Structured error parsing in ApiRequestError is well-designed.
  • Google OAuth: Correctly marks both new and linked users as verified.

Recommendation

Address #1 (HTML injection) before merging — it's a straightforward fix. Issues #2-4 are worth fixing but could be follow-up if needed.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 4, 2026

React Doctor

Score: 93 / 100

react-doctor v0.0.47

CI detected — scoring locally.

✔ Select projects to scan › frontend
Scanning changes: feat/email-verification → main

Scanning /home/runner/work/sparkth/sparkth/frontend...


  ⚠ Component "LoginContent" has 5 useState calls — consider useReducer for related state
    Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`
  app/login/LoginForm.tsx:32

  ┌────────────────────────────────────────────────────┐
  │ ┌─────┐                                            │
  │ │ ◠ ◠ │                                            │
  │ │  ▽  │                                            │
  │ └─────┘                                            │
  │ React Doctor (www.react.doctor)                    │
  │                                                    │
  │ 99 / 100  Great                                    │
  │                                                    │
  │ ██████████████████████████████████████████████████ │
  │                                                    │
  │ ⚠ 1 warning  across 1/6 files  in 189ms            │
  └────────────────────────────────────────────────────┘

  Full diagnostics written to /tmp/react-doctor-2954277e-417b-4b6c-9f33-0488bdf326b0

@hamza-56 hamza-56 self-assigned this May 4, 2026
Comment thread app/services/email_verification.py
Comment thread app/api/v1/auth.py
Comment thread app/api/v1/auth.py
Comment thread app/api/v1/auth.py Outdated
Comment thread app/api/v1/auth.py Outdated
Comment thread frontend/app/verify-email/page.tsx Outdated
Comment thread app/api/v1/auth.py
hamza-56 added 2 commits May 5, 2026 00:38
Prevents HTML/anchor injection from a user-supplied display name
(e.g. registering as '<a href="evil.site">click</a>'). Plain-text
body is unaffected.

Also returns (token, name) from request_resend so the resend endpoint
no longer needs a second User query after commit.
…edis

- Lowercase + strip email at registration and on Google OAuth (was
  case-sensitive, so 'Alice@Example.com' wouldn't match the resend
  lookup that lowercases). Also closes a duplicate-account door where
  case-different emails could both register.
- Replace double-commit in register_user with flush + single commit so
  user creation and verification-token creation are atomic — a failure
  in token creation no longer leaves an orphaned user row.
- Drop into redis.asyncio.from_url directly for the resend cooldown
  instead of reaching into CacheService._redis. CacheService wraps a
  different API (string get/set with ttl); we just need SET NX EX.
- Resend endpoint uses (token, name) returned by request_resend,
  removing the redundant User query after commit.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude finished @hamza-56's task in 2m 9s —— View job


Re-review: feat(auth): add email verification on registration

  • Check for previously addressed review comments and resolve threads
  • Review fix commits (263ead3, 55d29dd) for correctness
  • Re-review backend changes for any new or remaining issues
  • Re-review frontend changes
  • Check test coverage for new fixes
  • Post updated review summary

Previous Review Comments

All 7 threads from the initial review have been resolved:

# Issue Status
1 HTML injection in email template Fixed in 263ead3html.escape() on name and URL
2 Case-sensitive email mismatch Fixed in 55d29ddemail.strip().lower() at registration + OAuth
3 Non-atomic user + token creation Fixed in 55d29ddflush() + single commit()
4 Private _redis access Fixed in 55d29dd — direct redis.asyncio.from_url()
5 Unnecessary DB round-trip Fixed in 263ead3request_resend returns (token, name)
6 Email exposed in 403 Kept as-is — justified (requires correct password)
7 Suspense without fallback Kept as-is — consistent with existing login/page.tsx pattern

All fixes are correct and well-implemented. The new tests (test_email_is_normalized_to_lowercase, test_escapes_name_in_html_body) properly lock the fixes.


New Issue Found

Priority File Issue
🟡 Medium app/api/v1/auth.py:306-312 Redis client created per request is never closed_get_resend_redis() creates a new redis.asyncio client (with connection pool) on every resend request but never calls aclose(). Connections will leak over time. Either close after use or use a module-level singleton. See inline comment →

Overall Assessment

The fix commits cleanly address all critical and medium issues from the initial review. The code is in good shape:

  • Security: HTML injection fixed, token hashing solid, rate limiting correct
  • Correctness: Atomic registration, case-normalized emails, proper state machines
  • Tests: 38+ tests with thorough coverage including the new regression tests
  • Frontend: Clean state handling, proper error parsing, consistent patterns

The Redis connection leak (above) is the only remaining issue worth addressing before merge — it's a straightforward fix.

Comment thread app/api/v1/auth.py Outdated
aioredis.from_url() created a fresh client (and its connection pool)
on every /verify-email/resend request, with no aclose() — connections
leaked over time.

Wrap the constructor in an lru_cache(maxsize=1) singleton so we build
one client per process, matching the existing get_cache_service pattern
in app/core/cache.py. _get_resend_redis stays as the test-override
seam.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude finished @hamza-56's task in 3m 28s —— View job


Re-review: feat(auth): add email verification on registration

  • Check for previously addressed review comments — resolve threads
  • Review latest fix commits for correctness
  • Review backend changes (models, services, API, email, config)
  • Review migration
  • Review frontend changes
  • Review test coverage
  • Security review
  • Post inline comments and summary

Previous Review Comments

All 8 threads from the prior two reviews have been resolved:

# Issue Status
1 HTML injection in email template Fixed in 263ead3html.escape() on name and URL
2 Case-sensitive email mismatch Fixed in 55d29ddemail.strip().lower() at registration + OAuth
3 Non-atomic user + token creation Fixed in 55d29ddflush() + single commit()
4 Private _redis access Fixed in 55d29dd — direct redis.asyncio.from_url()
5 Unnecessary DB round-trip Fixed in 263ead3request_resend returns (token, name)
6 Email exposed in 403 Kept as-is — justified (requires correct password)
7 Suspense without fallback Kept as-is — consistent with existing login/page.tsx
8 Redis client created per request Fixed in 4067fa4lru_cache(maxsize=1) singleton

All fixes are correct and well-implemented. The regression tests (test_email_is_normalized_to_lowercase, test_escapes_name_in_html_body) properly lock the fixes.


New Issues Found

All low priority — none are blocking.

Priority File Issue
🔵 Low app/services/email_verification.py:107 _build_verify_url doesn't strip trailing slash from FRONTEND_BASE_URL — produces https://app.test//verify-email if misconfigured. See inline comment →
🔵 Low app/services/email_verification.py:53 used_at conflates "verified" with "superseded" — fine for current use, but limits future auditability. See inline comment →
🔵 Low app/api/v1/auth.py:313-323 Rate-limit Redis key persists if session.commit() fails — user sees 429 despite no email sent. Mitigated by short default cooldown (60s). See inline comment →

What Looks Good

  • Security: SHA-256 hashed tokens (raw never persisted), secrets.token_urlsafe(32), HTML-escaped email template, prior-token invalidation on new mint — all solid.
  • Atomicity: Registration now uses flush()create_token() → single commit(). No orphaned users without tokens.
  • Email normalization: email.strip().lower() at both registration and Google OAuth prevents case-sensitive duplicate accounts and resend lookup mismatches.
  • Rate limiting: Redis SET NX EX with lru_cache singleton — correct primitive, no connection leaks.
  • Migration: Clean schema addition with sa.func.now() for cross-DB backfill. Proper downgrade. Indexes on user_id and token_hash.
  • Exception handling: Specific exceptions throughout (TokenExpiredError, TokenInvalidError, SMTPException, ValueError). No bare except Exception.
  • Test coverage: 38+ tests across service-layer unit tests, endpoint integration tests, Google OAuth verification, SMTP composition, rate-limit behavior, and regression tests for the security/correctness fixes.
  • Frontend: Clean state machines for both the verify-email page and the login resend CTA. ApiRequestError with structured error parsing is well-designed. Registration shows "check your email" state cleanly.

Recommendation

Ready to merge. All critical and medium issues from prior reviews are addressed. The remaining low-priority items are non-blocking suggestions for follow-up.

Replaces the lru_cache singleton from 4067fa4 with a build-then-close
pattern. The endpoint is rate-limited to ~1 request per email per
cooldown window, so per-request pool setup is negligible — and this
removes the shared mutable module state. aclose() guarantees cleanup.

Adds a no-op aclose() to the FakeRedis test shim to match the real
client's lifecycle.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude finished @hamza-56's task in 3m 26s —— View job


Re-review: feat(auth): add email verification on registration

  • Check for previously addressed review comments — resolve threads
  • Review latest fix commits for correctness
  • Review backend changes (models, services, API, email, config)
  • Review migration
  • Review frontend changes
  • Review test coverage
  • Security review
  • Post inline comments and summary

Previous Review Comments

All 8 threads from prior reviews have been resolved:

# Issue Status
1 HTML injection in email template Fixed in 263ead3html.escape() on name and URL
2 Case-sensitive email mismatch Fixed in 55d29ddemail.strip().lower() at registration + OAuth
3 Non-atomic user + token creation Fixed in 55d29ddflush() + single commit()
4 Private _redis access Fixed in 55d29dd — direct redis.asyncio.from_url()
5 Unnecessary DB round-trip Fixed in 263ead3request_resend returns (token, name)
6 Email exposed in 403 Kept as-is — justified (requires correct password first)
7 Suspense without fallback Kept as-is — consistent with existing login/page.tsx
8 Redis client leak Fixed in d6b639ctry/finally + aclose() per request

All fixes are correct and well-implemented, with regression tests locking the key fixes (test_email_is_normalized_to_lowercase, test_escapes_name_in_html_body).


New Issues

Priority File Issue
🔵 Low app/core/email.py:22 Manual From header construction — uses f-string instead of email.utils.formataddr((name, addr)). Will produce a malformed header if SMTP_FROM_NAME ever contains ", ,, or non-ASCII chars. Low risk since it's config-controlled, but formataddr is safer. See inline comment.
🔵 Low app/services/email_verification.py:107 Trailing slash in FRONTEND_BASE_URL produces double-slash URL — if configured as https://app.example.com/, the verification link becomes .com//verify-email?token=.... Browsers normalize it, but it looks unprofessional in emails. A .rstrip('/') would fix it. (Flagged by prior incomplete review.)
🔵 Low app/services/email_verification.py:53 used_at conflates "verified" with "superseded" — prior tokens get used_at = now when a new token is minted, but they weren't actually used — they were invalidated. Not blocking; just worth knowing if token audit trails ever matter. (Flagged by prior incomplete review.)

What Looks Good

  • Token security: SHA-256 hashing (secrets.token_urlsafe(32) = 256 bits entropy), raw token never persisted, prior-token invalidation on new mint, proper used_at gating on verification.
  • Atomic registration: flush()create_token() → single commit() ensures no orphan users without tokens.
  • Email escaping: Both html.escape(name) and html.escape(url, quote=True) correctly prevent injection in the HTML email body.
  • Rate limiting: Redis SET NX EX is the right primitive. Silent 202 for unknown/verified emails prevents user enumeration on the resend endpoint.
  • Redis lifecycle: try/finally + aclose() on the per-request client is clean and avoids both connection leaks and shared mutable state.
  • Migration: Clean schema addition with sa.func.now() for cross-DB backfill. Proper downgrade. Indexes on user_id and token_hash.
  • Google OAuth: Correctly marks both new and linked users as verified, with email normalization.
  • Test coverage: 38+ tests covering token lifecycle, all four endpoints, Google OAuth verification, SMTP composition, rate limiting, HTML escaping, and email normalization. The conftest.py autouse SMTP stub and FakeRedis shim with aclose() are well-designed.
  • Frontend: Clean state machines for both the verify-email page (loading/success/expired/invalid) and resend CTA (idle/sending/sent/rate_limited/error). Structured error parsing in ApiRequestError with code/data fields is well-designed.

Overall Assessment

The PR is in excellent shape. All critical and medium issues from prior reviews have been addressed with clean, correct fixes. The remaining observations are all low-priority and non-blocking. The architecture is solid — hashed tokens, atomic transactions, proper rate limiting, background email sends, and thorough test coverage.

Ready to merge. The three remaining low-priority suggestions (formataddr, trailing slash, used_at semantics) are all follow-up material.

Comment thread app/services/email_verification.py Outdated


def _build_verify_url(raw_token: str) -> str:
return f"{settings.FRONTEND_BASE_URL}/verify-email?token={raw_token}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Trailing slash in FRONTEND_BASE_URL produces double-slash URL

If FRONTEND_BASE_URL is configured as https://app.example.com/, this yields https://app.example.com//verify-email?token=.... Functionally harmless (browsers and most email clients normalize it) but looks unprofessional in the email and could confuse link scanners.

Suggested change
return f"{settings.FRONTEND_BASE_URL}/verify-email?token={raw_token}"
return f"{settings.FRONTEND_BASE_URL.rstrip('/')}/verify-email?token={raw_token}"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f74cd0c. _build_verify_url now does settings.FRONTEND_BASE_URL.rstrip("/") so configs ending in / no longer produce //verify-email. Added test_strips_trailing_slash_in_frontend_base_url to lock it.

)
)
for prior in result.all():
prior.used_at = now
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — used_at conflates "verified" with "superseded"

Setting used_at = now on prior tokens reuses the same column for two different semantics: "the user actually clicked this token" vs. "this token was invalidated because a newer one was minted." If you ever need to audit which token was actually verified (vs. just invalidated), you can't distinguish the two.

Not a blocking issue — the current behavior is correct. Just noting that if auditing becomes important, a separate invalidated_at column or a status enum would be cleaner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping as-is. The reviewer notes this is non-blocking, and the design intent is "any token row not currently active." For audit, the workflow is to find the token whose used_at is closest to the user record's email_verified_at — they are written together inside verify_token. Adding an invalidated_at column or a status enum would be cheap if a future flow needs to distinguish (e.g. analytics on resend churn), but YAGNI today.

Comment thread app/api/v1/auth.py
Comment on lines +313 to +323
"1",
ex=settings.EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS,
nx=True,
)
finally:
await redis.aclose()
if not accepted:
raise HTTPException(status_code=429, detail="rate_limited")

result = await EmailVerificationService.request_resend(session, email=email_lower)
if result is None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Rate-limit lock persists if the DB operation fails

If session.commit() on line 330 raises (e.g. transient DB error), the Redis SET NX has already been consumed. The user gets a "rate_limited" response on their next attempt even though no email was sent.

The 60-second default cooldown makes this very low impact — the user just waits a minute. But if the cooldown is raised significantly, consider wrapping in a try/except that deletes the Redis key on DB failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping as-is. With the 60s default cooldown the worst case is "user waits one extra minute," which is below the threshold worth a compensating delete + the failure-mode complexity that introduces (e.g. the delete itself can fail and now we have two paths leaking state). If we ever raise the cooldown to, say, an hour, this would be worth revisiting — flagging in the follow-up that addresses the rate-limit shape mentioned in #289.

Replaces the explicit try/finally + aclose() in the endpoint with
`async with _get_resend_redis() as redis: ...`. `_get_resend_redis` is
now an @asynccontextmanager that owns the open/close pair, so the
caller never has to remember to close, and the test FakeRedis no
longer needs an aclose() shim.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude finished @hamza-56's task in 1m 50s —— View job


Re-review: feat(auth): add email verification on registration

  • Check for previously addressed review comments — resolve threads
  • Review latest fix commits for correctness
  • Review backend changes (models, services, API, email, config)
  • Review migration
  • Review frontend changes
  • Review test coverage
  • Security review
  • Post inline comments and summary

Previous Review Comments

All 8 threads from the initial and second reviews have been resolved:

# Issue Status
1 HTML injection in email template Fixed in 263ead3html.escape() on name and URL
2 Case-sensitive email mismatch Fixed in 55d29ddemail.strip().lower() at registration + OAuth
3 Non-atomic user + token creation Fixed in 55d29ddflush() + single commit()
4 Private _redis access Fixed in 55d29dd — direct redis.asyncio.from_url()
5 Unnecessary DB round-trip Fixed in 263ead3request_resend returns (token, name)
6 Email exposed in 403 Kept as-is — justified (requires correct password)
7 Suspense without fallback Kept as-is — consistent with existing login/page.tsx pattern
8 Redis client created per request never closed Fixed in e2a2c35asynccontextmanager with try/finally + aclose()

3 low-priority suggestions from the third review remain unresolved — these are non-blocking:

# Issue Status
9 Trailing slash in FRONTEND_BASE_URL Open_build_verify_url doesn't rstrip('/')
10 used_at conflates verified vs superseded Open — design suggestion for future auditability
11 Rate-limit lock persists on DB failure Open — mitigated by short 60s default cooldown

Latest Commit Review (e2a2c35)

The refactor from try/finally + aclose() to @asynccontextmanager is clean and correct:

  • Resource cleanup is guaranteed by the context manager's finally block
  • The test shim (FakeRedis + get_fake_redis) correctly uses @asynccontextmanager with yield
  • The endpoint code is now simpler: async with _get_resend_redis() as redis: replaces the manual try/finally

No issues with this commit.


No New Issues Found

The code is in good shape across all layers. No new critical, high, or medium issues.


What Looks Good

  • Token security: SHA-256 hashing (secrets.token_urlsafe(32)), raw token never persisted, prior-token invalidation on new mint, token_hash uniquely indexed.
  • Atomicity: Registration uses flush()create_token() → single commit(). No orphaned users without tokens.
  • Email normalization: email.strip().lower() at registration, Google OAuth, and resend — consistent case handling across all entry points.
  • HTML escaping: html.escape(name) and html.escape(url, quote=True) in the email template, with a regression test.
  • Redis lifecycle: @asynccontextmanager pattern ensures aclose() on every path. Clean test-override seam via monkeypatch.setattr.
  • Rate limiting: Redis SET NX EX is the right primitive. Silent 202 for unknown/verified emails prevents user enumeration.
  • Migration: Clean schema addition with server_default=sa.false(), sa.func.now() backfill for cross-DB compatibility, proper downgrade.
  • Exception handling: Specific exceptions throughout (TokenExpiredError, TokenInvalidError, SMTPException, ValueError). No bare except Exception.
  • Test coverage: 38+ tests — service-layer unit tests, endpoint integration tests, Google OAuth verification, SMTP composition, rate-limit behavior, email normalization, HTML escaping regression tests. conftest.py autouse stub is clean.
  • Frontend: Clean state machines for verify-email page and login resend CTA. ApiRequestError with structured error parsing (code, data) is well-designed. Registration "check your email" state transitions cleanly.
  • Schemas: email_verified exposed in User response model; VerifyEmailRequest and ResendVerificationRequest use EmailStr / plain str appropriately.

Recommendation

Ready to merge. All critical and medium issues from prior reviews are addressed. The 3 remaining open threads are low-priority suggestions suitable for follow-up.

Comment thread app/core/email.py Outdated
raise RuntimeError("SMTP not configured: set SMTP_HOST")

message = EmailMessage()
message["From"] = f'"{settings.SMTP_FROM_NAME}" <{settings.SMTP_FROM_EMAIL}>'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Manual From header may produce malformed RFC 5322 if display name contains special characters

The f-string f'"{name}" <{email}>' works for simple names like "Sparkth", but will break subtly if SMTP_FROM_NAME contains double quotes, commas, or non-ASCII characters (e.g. Sparkth "Beta" → malformed header).

Python's email.utils.formataddr handles quoting, escaping, and RFC 2047 encoding automatically:

Suggested change
message["From"] = f'"{settings.SMTP_FROM_NAME}" <{settings.SMTP_FROM_EMAIL}>'
message["From"] = formataddr((settings.SMTP_FROM_NAME, settings.SMTP_FROM_EMAIL))

with from email.utils import formataddr at the top.

Low risk since this is config-controlled, but formataddr is the safer default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f74cd0c. From is now built with email.utils.formataddr((SMTP_FROM_NAME, SMTP_FROM_EMAIL)), which handles RFC 5322 quoting and RFC 2047 encoding for non-ASCII names. Existing email-composition test still passes (the simple "Test" <…> case round-trips identically through formataddr).

@hamza-56 hamza-56 mentioned this pull request May 4, 2026
10 tasks
Adds validate_password_complexity() in app/core/security: rejects
passwords shorter than 8 chars or missing an uppercase letter, a digit,
or a non-alphanumeric character. UserCreate schema runs it via a
field_validator so it's enforced at the API boundary and surfaces as
a 422 with a field-level error.

Frontend register form shows the rule under the password input as a
hint, hiding it once a server-side error replaces it.

Updates the few existing register-flow tests that submitted weak
passwords; adds parametrized tests for each rule plus a strong-password
happy-path.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude finished @hamza-56's task in 2m 36s —— View job


Re-review: feat(auth): add email verification on registration

  • Check for previously addressed review comments — resolve threads
  • Review new commits since last review
  • Review backend changes (models, services, API, email, config)
  • Review migration
  • Review frontend changes
  • Review test coverage
  • Security review
  • Post inline comments and summary

Previous Review Comments

All 8 critical/medium threads from prior reviews are resolved:

# Issue Status
1 HTML injection in email template Fixed in 263ead3html.escape() on name and URL
2 Case-sensitive email mismatch Fixed in 55d29ddemail.strip().lower() at registration + OAuth
3 Non-atomic user + token creation Fixed in 55d29ddflush() + single commit()
4 Private _redis access Fixed in 55d29dd — direct redis.asyncio.from_url()
5 Unnecessary DB round-trip Fixed in 263ead3request_resend returns (token, name)
6 Email exposed in 403 Kept as-is — justified (requires correct password first)
7 Suspense without fallback Kept as-is — consistent with existing login/page.tsx
8 Redis client leak Fixed in e2a2c35asynccontextmanager with try/finally + aclose()

4 low-priority threads from prior reviews remain open (non-blocking):

# Issue Status
9 Trailing slash in FRONTEND_BASE_URL Open — .rstrip('/') suggestion
10 used_at conflates verified vs superseded Open — design suggestion
11 Rate-limit lock persists on DB failure Open — mitigated by 60s cooldown
12 Manual From header construction Open — formataddr suggestion

Latest Commit Review (ff3998f — password complexity)

Clean addition of server-side password complexity enforcement:

  • validate_password_complexity() in app/core/security.py — regex checks for length, uppercase, digit, special char
  • Pydantic field_validator on UserCreate.password ensures enforcement at the API boundary (422 response)
  • Frontend register form shows the rule as a hint, hidden when a server-side error replaces it
  • All existing tests updated from weak "testpassword""Sup3rSecret!", plus parametrized tests for each rule

New Issues

All low priority — none blocking.

Priority File Issue
🔵 Low app/core/security.py:29-34 Single compound condition returns one generic error message — splitting into individual checks would give per-rule feedback (e.g. "must include a digit"). See inline comment →
🔵 Low app/core/security.py:33 No maximum password length — without an upper bound, a multi-MB password forces bcrypt to hash it all (HashDoS). Consider capping at 128 chars. See inline comment →

What Looks Good

  • Token security: SHA-256 hashing (secrets.token_urlsafe(32) = 256 bits entropy), raw token never persisted, prior-token invalidation on new mint, token_hash uniquely indexed.
  • Atomic registration: flush()create_token() → single commit() ensures no orphan users without tokens.
  • Email normalization: email.strip().lower() at registration, Google OAuth, and resend — consistent across all entry points.
  • HTML escaping: html.escape(name) and html.escape(url, quote=True) in the email template, locked by regression test.
  • Redis lifecycle: @asynccontextmanager with try/finally + aclose() — clean resource management, no leaks.
  • Rate limiting: Redis SET NX EX is the right primitive. Silent 202 for unknown/verified emails prevents user enumeration.
  • Password complexity: Enforced at the Pydantic schema layer via field_validator — clean separation, surfaces as field-level 422. Parametrized tests cover each rule plus the happy path.
  • Migration: Clean schema addition with server_default=sa.false(), sa.func.now() backfill for cross-DB compat, proper downgrade.
  • Exception handling: Specific exceptions throughout (TokenExpiredError, TokenInvalidError, SMTPException, ValueError). No bare except Exception.
  • Test coverage: 38+ tests — service-layer unit tests, endpoint integration tests, Google OAuth verification, SMTP composition, rate-limit behavior, email normalization, HTML escaping regression, and password complexity parametrized tests. conftest.py autouse SMTP stub is clean.
  • Frontend: Clean state machines for verify-email page and login resend CTA. Password hint shown conditionally. ApiRequestError with structured error parsing is well-designed.

Recommendation

Ready to merge. All critical and medium issues from prior reviews are addressed. The latest commit (password complexity) is clean and well-tested. The remaining open threads are all low-priority suggestions suitable for follow-up.

Comment thread app/core/security.py
Comment on lines +29 to +34
if (
len(password) < 8
or not re.search(r"[A-Z]", password)
or not re.search(r"\d", password)
or not re.search(r"[^A-Za-z0-9]", password)
):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Single compound condition obscures which rule failed

The validator returns one generic message regardless of which rule the password violated. Splitting into individual checks would let you return a specific message (e.g. "must include a digit") — better for UX and for the 422 response the frontend renders.

Not blocking — the current approach is simpler and the frontend already shows a static hint. But if you want per-rule feedback:

Suggested change
if (
len(password) < 8
or not re.search(r"[A-Z]", password)
or not re.search(r"\d", password)
or not re.search(r"[^A-Za-z0-9]", password)
):
if len(password) < 8:
raise ValueError("Password must be at least 8 characters.")
if not re.search(r"[A-Z]", password):
raise ValueError("Password must include at least one uppercase letter.")
if not re.search(r"\d", password):
raise ValueError("Password must include at least one number.")
if not re.search(r"[^A-Za-z0-9]", password):
raise ValueError("Password must include at least one special character.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping as-is. The frontend already shows the rules as inline hint text under the password input, so by the time a user submits a weak password they have the full ruleset visible. Splitting into per-rule messages would mean the user sees the same information in two places phrased two different ways — net negative for clarity. Happy to revisit if a UX requirement comes up that benefits from "you missed exactly X" feedback.

Comment thread app/core/security.py
len(password) < 8
or not re.search(r"[A-Z]", password)
or not re.search(r"\d", password)
or not re.search(r"[^A-Za-z0-9]", password)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — No maximum password length

There's no upper bound on password length. Without one, an attacker can submit a multi-MB password to force the bcrypt hash to chew through it (HashDoS). Most frameworks cap at 72 bytes (bcrypt's internal limit) or a reasonable upper bound like 128 chars. Consider adding:

if len(password) > 128:
    raise ValueError("Password must not exceed 128 characters.")

Low priority since registration is already gated behind whitelist + rate limits, but it's a defense-in-depth measure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f74cd0c. validate_password_complexity now rejects any password longer than MAX_PASSWORD_LENGTH = 128 chars before running the complexity regexes. Added a parametrized case ("A1!" + "a"*200) that hits the cap. Defense-in-depth even though we already have the registration whitelist + auth rate limits.

- app/core/email.py: build From with email.utils.formataddr instead of
  a manual f-string. Handles RFC 5322 quoting/encoding so non-ASCII or
  punctuated SMTP_FROM_NAME values can't produce malformed headers.
- _build_verify_url: rstrip the trailing slash off FRONTEND_BASE_URL so
  configs ending in '/' don't produce '...//verify-email?token=...'.
- validate_password_complexity: reject passwords longer than 128
  characters as defense-in-depth against hash-DoS via large inputs.

Adds two tests: trailing-slash URL building, and a 128+ char password
rejected.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

…r hooks

- Add fallback={null} to the Suspense wrappers in app/login/page.tsx
  and app/verify-email/page.tsx so the omission is intentional and the
  React Doctor / linter sees a fallback at the boundary.
- Destructure useRouter() and useSearchParams() up front so each
  component's router/searchParams dependency surface is explicit and
  React Compiler can memoize without re-walking the wrapper objects.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

1 similar comment
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@hamza-56 hamza-56 requested review from abdulrafey1 and zaira-bibi May 4, 2026 20:23
…d Suspense

- VerifyEmailClient: collapse the verify useEffect's three setStatus
  branches into a single computed-then-set call, with a cancelled flag
  so an unmount mid-fetch can't update unmounted state. Read-then-write
  is now exactly one setState per resolution.
- Co-locate the <Suspense fallback={null}> boundary with the
  useSearchParams hook by splitting LoginPage and VerifyEmailClient
  into a Suspense-wrapping shell + an inner component that owns the
  hook. Parent page.tsx wrappers stay (nested Suspense is harmless),
  but the boundary is now visible to per-file static analysis.
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

1 similar comment
@claude
Copy link
Copy Markdown

claude Bot commented May 4, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@hamza-56 hamza-56 changed the title feat(auth): add email verification on registration Add email verification on registration May 4, 2026
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.

[Sparkth] Email Verification

1 participant