Skip to content

Implement email verification flow for user registration #68

Description

@grantfox-oss

Description

Require email verification for all new user registrations. Send verification emails with 24-hour expiry links, support resend functionality, restrict unverified users from accessing certain features, and display a verified badge. Include comprehensive tests for email delivery, token expiry, and verification state transitions.

  • Reduce spam and fake account registrations.
  • Ensure users can access their registered email accounts.
  • Create a foundational security checkpoint for account recovery and notifications.
  • Improve user trust with a verification badge.

Scope

  • Add email verification state tracking to User model.
  • Send transactional email on registration containing a verification link.
  • Generate secure, time-bound verification tokens (24-hour expiry).
  • Create resend verification email endpoint.
  • Restrict unverified users from sensitive features (donations, distributions, KYC).
  • Display verified status badge on user profiles and campaign pages.
  • Add comprehensive tests for email delivery, token expiry, and state transitions.

Database changes (Prisma schema)

Update schema.prisma User model:

model User {
  // ... existing fields
  emailVerified      Boolean       @default(false)
  verificationToken  String?       @unique
  verificationExpiry DateTime?
  // ... rest of model
}

Add new model for audit trail (optional but recommended):

model VerificationLog {
  id        String   @id @default(cuid())
  userId    String
  action    String   // 'SENT' | 'VERIFIED' | 'EXPIRED' | 'RESENT'
  tokenHash String?  // hash of token, not plaintext
  ipAddress String?
  userAgent String?
  createdAt DateTime @default(now())

  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@index([createdAt])
}

Rationale: Track verification attempts for security audit and abuse detection.


API contracts (endpoints)

Registration endpoint (existing, modified behavior)

  • POST /api/v1/auth/register
    • Behavior change: on successful user creation, immediately:
      • Generate verification token (secure random string, 32+ bytes, base64-encoded)
      • Set verificationExpiry = now() + 24 hours
      • Send verification email with link: {baseUrl}/verify-email?token={token}
      • Create VerificationLog entry with action SENT
    • Response: { success: true, message: 'User created. Check your email to verify.', userId }
    • Do not auto-login; require email verification before first login (see restrictions below)

Verify email endpoint (new)

  • GET /api/v1/auth/verify-email?token=<token>
    • Query param: token (verification token from email link)
    • No authentication required
    • Behavior:
      • Look up user by verificationToken
      • Check verificationExpiry > now() (reject if expired)
      • On success:
        • Set emailVerified = true
        • Clear verificationToken and verificationExpiry
        • Create VerificationLog with action VERIFIED
        • Return: { success: true, message: 'Email verified. You can now log in.' }
      • On failure (expired/invalid):
        • Return: { success: false, message: 'Verification link expired or invalid.', code: 'VERIFICATION_FAILED' }
        • Suggest resend via endpoint below
    • Optionally auto-redirect to frontend URL after verification

Resend verification email endpoint (new)

  • POST /api/v1/auth/resend-verification
    • Body: { email }
    • No authentication required
    • Behavior:
      • Find user by email
      • Check if already verified → return { already_verified: true }
      • Generate new token and set new expiry (24h from now)
      • Resend email
      • Create VerificationLog with action RESENT
      • Rate-limit: max 3 resends per hour per email (use Redis)
      • Return: { success: true, message: 'Verification email sent.' }

Verification status endpoint (existing profile endpoint, extended)

  • GET /api/v1/auth/me or GET /api/v1/users/profile
    • Extended response to include: { emailVerified: boolean, verificationSentAt?: DateTime }

Unverified user restrictions

Implement role/permission checks in controllers to block unverified users from:

  • POST /api/v1/donations — only verified users can donate
  • POST /api/v1/beneficiaries/:id/kyc — only verified beneficiaries can submit KYC
  • POST /api/v1/distributions — only verified organization staff can create distributions
  • Admin/verifier actions (recommend verified-only for sensitive roles)

In auth.ts or per-controller, add check:

if (req.user && !req.user.emailVerified && isRestrictedEndpoint(req)) {
  throw new AppError('Please verify your email before accessing this feature.', 403);
}

Provide clear error message: { success: false, code: 'EMAIL_NOT_VERIFIED', message: 'Please verify your email. Resend link?', resendUrl: '/api/v1/auth/resend-verification' }

Return 403 Forbidden with the resend link in response body.


Email template

Send transactional email on registration and resend:

Subject: Verify your AidLink account

Body (HTML + plain text):

Hi {firstName},

Welcome to AidLink! To get started, please verify your email address by clicking the link below:

{verificationLink}

This link will expire in 24 hours.

If you didn't create this account, please ignore this email.

Questions? Support: support@aidlink.org

Plain text fallback: same but without HTML tags.

Verification link format: https://app.aidlink.org/verify-email?token={token}

  • Token embedded in URL (querystring)
  • Backend validates token format and expiry on GET /api/v1/auth/verify-email?token=...

Verified badge

User profile response

Add field to user responses:

{
  "id": "...",
  "email": "...",
  "emailVerified": true,
  "verifiedAt": "2026-06-18T12:00:00Z",
  ...
}

Campaign/beneficiary profile response

When showing a campaign owner or beneficiary, include:

{
  "organizationName": "...",
  "owner": {
    "id": "...",
    "name": "...",
    "emailVerified": true
  },
  ...
}

Frontend badge

Display a checkmark or "Verified" badge next to verified user names in:

  • Campaign owner info
  • Beneficiary profile
  • Donor profiles (if public)
  • Admin/verifier credentials

Token generation & security

  • Generate token: crypto.randomBytes(32).toString('base64url')
  • Store in DB: use hash of token (SHA256) to avoid exposing plaintext in DB
    • On verification, hash incoming token and compare with stored hash
  • Example in code:
    const token = crypto.randomBytes(32).toString('base64url');
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
    await prisma.user.update({
      where: { id },
      data: {
        verificationToken: tokenHash,
        verificationExpiry: new Date(Date.now() + 24 * 60 * 60 * 1000),
      },
    });
    // Send email with plaintext token

Business rules

  • Email case-insensitivity: store emails lowercase; compare case-insensitively
  • Token reuse prevention: each resend generates a new token; old tokens become invalid
  • Rate limiting:
    • Resend: max 3 per hour per email
    • Verification attempts: track failed attempts; lock after 10 failed attempts (require resend)
  • Expiry handling:
    • Expired tokens are invalid; user must request resend
    • Show friendly message and resend link in error response
  • Already-verified users: attempting to verify again (e.g., old link) should return success or a friendly "already verified" message
  • Login before verification: unverified users should not be able to obtain JWT tokens on login (or issue limited-scope tokens that only allow verification endpoints)

Notification flow

  1. User submits registration form
  2. API validates and creates User with emailVerified = false
  3. Immediately send verification email (async job or synchronous call)
  4. Return success response (do NOT auto-login)
  5. User checks email and clicks link
  6. API verifies token, sets emailVerified = true, logs event
  7. Redirect user to login or auto-login screen
  8. On login, check emailVerified; if false, reject and prompt resend

Testing plan

Unit tests (src/services/auth.service.test.ts)

  • Generate verification token: valid format, 32+ bytes
  • Verify email with valid token:
    • Token matches, not expired → user marked verified
    • Token invalid → returns error
    • Token expired → returns error with resend suggestion
  • Resend verification:
    • User not yet verified → new token issued, email sent
    • User already verified → returns already_verified or success gracefully
    • Rate limit enforced (max 3 resends/hour)
  • Token expiry: token generation sets correct 24-hour expiry

Integration tests (src/routes/auth.routes.test.ts)

  • Full flow:
    • POST /api/v1/auth/register → user created, email queued
    • Mock email delivery; verify email body contains token
    • GET /api/v1/auth/verify-email?token=<token> → user verified
    • POST /api/v1/auth/login → now allowed (user verified)
  • Expired token:
    • Create user with token expiry set to past
    • GET /api/v1/auth/verify-email?token=<old> → fails
    • POST /api/v1/auth/resend-verification → new token issued
  • Unverified restrictions:
    • Create unverified user
    • Attempt POST /api/v1/donations → rejected with 403 and resend link
    • Verify email
    • Retry POST /api/v1/donations → allowed

Email delivery tests

  • Mock email service (e.g., Nodemailer, SendGrid SDK)
  • Verify email is sent on:
    • User registration
    • Resend verification
  • Verify email contains correct token and link format
  • Verify subject and body text

Token expiry tests

  • Set token expiry to 1 second in future
  • Wait 2 seconds
  • Attempt verification → fails with expiry error

Concurrency tests

  • Simulate two resend requests in quick succession (within 1 second)
  • Verify only one email is sent and rate limit is enforced

Security tests

  • Token is not returned in any API response (only sent via email)
  • Token hash is stored in DB, not plaintext
  • Attempt to guess/brute-force verification tokens fails after N attempts
  • Verification tokens are unique per user and per request (cannot reuse old token after new one issued)

Implementation checklist

  • Add emailVerified, verificationToken, verificationExpiry fields to User model in Prisma schema
  • Add optional VerificationLog model for audit trail
  • Run Prisma migration
  • Implement token generation utility (crypto-based, 32+ bytes)
  • Implement token hashing utility (SHA256)
  • Update AuthService.register() to:
    • Generate token
    • Set expiry
    • Send email (async)
    • Create verification log
  • Create verifyEmail(token) method in AuthService
  • Create resendVerificationEmail(email) method with rate limiting
  • Add email template file (src/templates/verify-email.html and .txt)
  • Add GET /api/v1/auth/verify-email?token= endpoint
  • Add POST /api/v1/auth/resend-verification endpoint
  • Add emailVerified check middleware or per-controller guard for restricted endpoints
  • Update POST /api/v1/auth/login to reject unverified users (or issue limited token)
  • Update user profile response to include emailVerified flag
  • Add verified badge display logic (or document for frontend)
  • Add unit tests for token generation, verification, expiry
  • Add integration tests for full flow, restrictions, rate limiting
  • Add email delivery mocks and tests
  • Add security tests (brute force, token guessing, etc.)
  • Update API docs (openapi.yaml) with new endpoints
  • Add error handling and user-friendly messages
  • Document verification flow in README.md

Files likely to change

  • schema.prisma (add emailVerified, verificationToken, verificationExpiry, optional VerificationLog)
  • auth.service.ts (register, verify, resend methods)
  • auth.controller.ts (verify, resend endpoints)
  • auth.routes.ts (add /verify-email, /resend-verification routes)
  • auth.ts (add email verification check for restricted endpoints)
  • src/utils/email.ts or similar (email sending utility)
  • src/templates/verify-email.html and .txt (new email templates)
  • tests/auth.service.test.ts (unit tests)
  • tests/auth.routes.test.ts (integration tests)
  • openapi.yaml (document new endpoints)
  • README.md (verification flow documentation)

Environment variables & config

  • EMAIL_FROM — sender address (e.g., noreply@aidlink.org)
  • EMAIL_PROVIDER — provider (sendgrid, nodemailer, aws-ses, etc.)
  • VERIFICATION_TOKEN_EXPIRY_HOURS — default 24
  • VERIFICATION_RESEND_RATE_LIMIT — resends per hour (default 3)
  • VERIFICATION_MAX_FAILED_ATTEMPTS — max failed verifications before lockout (default 10)
  • APP_BASE_URL — URL for verification link in email (e.g., https://app.aidlink.org)

Acceptance criteria

  • New users must verify email before accessing restricted features
  • Verification link expires after 24 hours
  • Resend endpoint works with rate limiting (max 3/hour)
  • Token is secure (not exposed in responses, hashed in DB)
  • Unverified users see clear, actionable error messages with resend links
  • Verified badge displays on profiles
  • All tests pass (unit, integration, email delivery, token expiry, security)
  • Documentation updated

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions