diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9cfe653..20a4445 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,17 +32,20 @@ enum UserStatus { } model User { - id String @id @default(cuid()) - email String @unique - username String? @unique - passwordHash String? - walletAddress String? @unique - role Role @default(DONOR) - status UserStatus @default(PENDING_VERIFICATION) - emailVerified Boolean @default(false) - lastLogin DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + username String? @unique + passwordHash String? + walletAddress String? @unique + role Role @default(DONOR) + status UserStatus @default(PENDING_VERIFICATION) + emailVerified Boolean @default(false) + verificationToken String? @unique + verificationExpiry DateTime? + failedVerifyAttempts Int @default(0) + lastLogin DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations sessions Session[] @@ -66,6 +69,21 @@ model User { @@index([status]) } +model VerificationLog { + id String @id @default(cuid()) + userId String + action String // 'SENT' | 'VERIFIED' | 'EXPIRED' | 'RESENT' | 'FAILED' + tokenHash String? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([createdAt]) +} + model Session { id String @id @default(cuid()) userId String diff --git a/src/config/__mocks__/database.ts b/src/config/__mocks__/database.ts index 33ca249..67afe0d 100644 --- a/src/config/__mocks__/database.ts +++ b/src/config/__mocks__/database.ts @@ -63,6 +63,19 @@ const prismaMock = { findUnique: jest.fn(), findMany: jest.fn(), count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + verificationLog: { + create: jest.fn(), + findMany: jest.fn(), + }, + session: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), }, auditLog: { create: jest.fn().mockResolvedValue({}), diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 40abd16..f11afce 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -7,12 +7,68 @@ import logger from '../config/logger'; export class AuthController { static async register(req: Request, res: Response, next: NextFunction): Promise { try { - const result = await AuthService.register(req.body); - + const result = await AuthService.register(req.body, { + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); + res.status(201).json({ success: true, - data: result, - message: 'User registered successfully', + data: { userId: result.userId }, + message: result.message, + }); + } catch (error) { + next(error); + } + } + + static async verifyEmail(req: Request, res: Response, next: NextFunction): Promise { + try { + const { token } = req.query; + + if (!token || typeof token !== 'string') { + throw new AppError('Verification token is required', 400); + } + + await AuthService.verifyEmail(token, { + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); + + res.status(200).json({ + success: true, + message: 'Email verified. You can now log in.', + }); + } catch (error) { + if (error instanceof AppError && error.statusCode === 400) { + res.status(400).json({ + success: false, + code: 'VERIFICATION_FAILED', + message: error.message, + resendUrl: '/api/v1/auth/resend-verification', + }); + return; + } + next(error); + } + } + + static async resendVerification(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email } = req.body; + + if (!email) { + throw new AppError('Email is required', 400); + } + + const result = await AuthService.resendVerificationEmail(email, { + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); + + res.status(200).json({ + success: true, + ...result, }); } catch (error) { next(error); @@ -22,13 +78,25 @@ export class AuthController { static async login(req: Request, res: Response, next: NextFunction): Promise { try { const result = await AuthService.login(req.body); - + res.status(200).json({ success: true, data: result, message: 'Login successful', }); } catch (error) { + if (error instanceof AppError && error.statusCode === 403 && !req.body._suppressVerifyHint) { + const message = error.message; + if (message.includes('verify your email')) { + res.status(403).json({ + success: false, + code: 'EMAIL_NOT_VERIFIED', + message, + resendUrl: '/api/v1/auth/resend-verification', + }); + return; + } + } next(error); } } @@ -37,7 +105,7 @@ export class AuthController { try { const { walletAddress, signature, message } = req.body; const result = await AuthService.walletAuth(walletAddress, signature, message); - + res.status(200).json({ success: true, data: result, @@ -51,13 +119,13 @@ export class AuthController { static async refreshToken(req: Request, res: Response, next: NextFunction): Promise { try { const { refreshToken } = req.body; - + if (!refreshToken) { throw new AppError('Refresh token is required', 400); } const tokens = await AuthService.refreshToken(refreshToken); - + res.status(200).json({ success: true, data: tokens, @@ -71,17 +139,14 @@ export class AuthController { static async logout(req: AuthRequest, res: Response, next: NextFunction): Promise { try { const token = req.headers.authorization?.substring(7); - + if (!req.user || !token) { throw new AppError('Authentication required', 401); } await AuthService.logout(req.user.id, token); - - res.status(200).json({ - success: true, - message: 'Logout successful', - }); + + res.status(200).json({ success: true, message: 'Logout successful' }); } catch (error) { next(error); } @@ -94,11 +159,8 @@ export class AuthController { } await AuthService.logoutAll(req.user.id); - - res.status(200).json({ - success: true, - message: 'Logged out from all devices', - }); + + res.status(200).json({ success: true, message: 'Logged out from all devices' }); } catch (error) { next(error); } @@ -111,11 +173,8 @@ export class AuthController { } const user = await AuthService.getUserById(req.user.id); - - res.status(200).json({ - success: true, - data: user, - }); + + res.status(200).json({ success: true, data: user }); } catch (error) { next(error); } diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 50652f8..b7ba30b 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import { JWTUtils } from '../utils/jwt'; import { AuthRequest } from '../types'; +import prisma from '../config/database'; import logger from '../config/logger'; import prisma from '../config/database'; @@ -11,12 +12,9 @@ export const authenticate = async ( ): Promise => { try { const authHeader = req.headers.authorization; - + if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ - success: false, - error: 'No token provided', - }); + res.status(401).json({ success: false, error: 'No token provided' }); return; } @@ -32,11 +30,36 @@ export const authenticate = async ( next(); } catch (error) { logger.error('Authentication error:', error); - res.status(401).json({ + res.status(401).json({ success: false, error: 'Invalid or expired token' }); + } +}; + +export const requireVerified = async ( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise => { + if (!req.user) { + res.status(401).json({ success: false, error: 'Authentication required' }); + return; + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { emailVerified: true }, + }); + + if (!user?.emailVerified) { + res.status(403).json({ success: false, - error: 'Invalid or expired token', + code: 'EMAIL_NOT_VERIFIED', + message: 'Please verify your email before accessing this feature.', + resendUrl: '/api/v1/auth/resend-verification', }); + return; } + + next(); }; export const authorize = (...roles: string[]) => { diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 217cb22..da5ed71 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -3,7 +3,7 @@ import { AuthController } from '../controllers/auth.controller'; import { authenticate } from '../middleware/auth'; import { validate } from '../middleware/validation'; import { registerSchema, loginSchema, walletAuthSchema } from '../utils/validation'; -import { authLimiter } from '../middleware/rateLimit'; +import { authLimiter, resendVerificationLimiter } from '../middleware/rateLimit'; const router = Router(); @@ -19,29 +19,42 @@ const router = Router(); * application/json: * schema: * type: object - * required: - * - email - * - password + * required: [email, password] * properties: - * email: - * type: string - * password: - * type: string - * username: - * type: string - * role: - * type: string + * email: { type: string } + * password: { type: string } + * username: { type: string } + * role: { type: string } * responses: * 201: - * description: User registered successfully + * description: User created. Check your email to verify. */ router.post('/register', authLimiter, validate(registerSchema), AuthController.register); /** * @swagger - * /api/v1/auth/login: + * /api/v1/auth/verify-email: + * get: + * summary: Verify email address + * tags: [Authentication] + * parameters: + * - in: query + * name: token + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Email verified successfully + * 400: + * description: Token invalid or expired + */ +router.get('/verify-email', AuthController.verifyEmail); + +/** + * @swagger + * /api/v1/auth/resend-verification: * post: - * summary: Login with email and password + * summary: Resend email verification link * tags: [Authentication] * requestBody: * required: true @@ -49,25 +62,22 @@ router.post('/register', authLimiter, validate(registerSchema), AuthController.r * application/json: * schema: * type: object - * required: - * - email - * - password + * required: [email] * properties: - * email: - * type: string - * password: - * type: string + * email: { type: string } * responses: * 200: - * description: Login successful + * description: Verification email sent + * 429: + * description: Rate limit exceeded */ -router.post('/login', authLimiter, validate(loginSchema), AuthController.login); +router.post('/resend-verification', resendVerificationLimiter, AuthController.resendVerification); /** * @swagger - * /api/v1/auth/wallet: + * /api/v1/auth/login: * post: - * summary: Authenticate with wallet + * summary: Login with email and password * tags: [Authentication] * requestBody: * required: true @@ -75,20 +85,24 @@ router.post('/login', authLimiter, validate(loginSchema), AuthController.login); * application/json: * schema: * type: object - * required: - * - walletAddress - * - signature - * - message + * required: [email, password] * properties: - * walletAddress: - * type: string - * signature: - * type: string - * message: - * type: string + * email: { type: string } + * password: { type: string } * responses: * 200: - * description: Wallet authentication successful + * description: Login successful + * 403: + * description: Email not verified + */ +router.post('/login', authLimiter, validate(loginSchema), AuthController.login); + +/** + * @swagger + * /api/v1/auth/wallet: + * post: + * summary: Authenticate with Stellar wallet + * tags: [Authentication] */ router.post('/wallet', authLimiter, validate(walletAuthSchema), AuthController.walletAuth); @@ -98,20 +112,6 @@ router.post('/wallet', authLimiter, validate(walletAuthSchema), AuthController.w * post: * summary: Refresh access token * tags: [Authentication] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - refreshToken - * properties: - * refreshToken: - * type: string - * responses: - * 200: - * description: Token refreshed successfully */ router.post('/refresh', AuthController.refreshToken); @@ -123,9 +123,6 @@ router.post('/refresh', AuthController.refreshToken); * tags: [Authentication] * security: * - bearerAuth: [] - * responses: - * 200: - * description: Logout successful */ router.post('/logout', authenticate, AuthController.logout); @@ -137,9 +134,6 @@ router.post('/logout', authenticate, AuthController.logout); * tags: [Authentication] * security: * - bearerAuth: [] - * responses: - * 200: - * description: Logged out from all devices */ router.post('/logout-all', authenticate, AuthController.logoutAll); @@ -147,13 +141,10 @@ router.post('/logout-all', authenticate, AuthController.logoutAll); * @swagger * /api/v1/auth/me: * get: - * summary: Get current user + * summary: Get current user profile (includes emailVerified) * tags: [Authentication] * security: * - bearerAuth: [] - * responses: - * 200: - * description: User data retrieved successfully */ router.get('/me', authenticate, AuthController.getMe); diff --git a/src/routes/beneficiary.routes.ts b/src/routes/beneficiary.routes.ts index 7eaddea..d1e8252 100644 --- a/src/routes/beneficiary.routes.ts +++ b/src/routes/beneficiary.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { BeneficiaryController } from '../controllers/beneficiary.controller'; -import { authenticate } from '../middleware/auth'; +import { authenticate, requireVerified } from '../middleware/auth'; import { z } from 'zod'; import { validate } from '../middleware/validation'; @@ -145,11 +145,12 @@ router.post( /** * @route POST /api/v1/beneficiaries/:id/kyc * @desc Submit KYC documents for beneficiary - * @access Private (Beneficiary) + * @access Private (Beneficiary — verified only) */ router.post( '/:id/kyc', authenticate, + requireVerified, validate(kycSubmissionSchema), BeneficiaryController.submitKYC ); diff --git a/src/routes/distribution.routes.ts b/src/routes/distribution.routes.ts index bdb67b5..b440cdc 100644 --- a/src/routes/distribution.routes.ts +++ b/src/routes/distribution.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { DistributionController } from '../controllers/distribution.controller'; -import { authenticate } from '../middleware/auth'; +import { authenticate, requireVerified } from '../middleware/auth'; import { distributionLimiter } from '../middleware/rateLimit'; import { z } from 'zod'; import { validate } from '../middleware/validation'; @@ -32,11 +32,12 @@ const addProofSchema = z.object({ /** * @route POST /api/v1/distributions * @desc Create a new distribution - * @access Private (Organization, Admin) + * @access Private (Organization, Admin — verified only) */ router.post( '/', authenticate, + requireVerified, distributionLimiter, validate(createDistributionSchema), DistributionController.createDistribution diff --git a/src/routes/donation.routes.ts b/src/routes/donation.routes.ts index 5cbb4f2..6c738b9 100644 --- a/src/routes/donation.routes.ts +++ b/src/routes/donation.routes.ts @@ -27,11 +27,12 @@ const confirmDonationSchema = z.object({ /** * @route POST /api/v1/donations * @desc Create a new donation - * @access Private + * @access Private (verified users only) */ router.post( '/', authenticate, + requireVerified, donationLimiter, validate(createDonationSchema), DonationController.createDonation diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 1897a90..0142efa 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,47 +1,84 @@ +import fs from 'fs'; +import path from 'path'; import prisma from '../config/database'; +import redis from '../config/redis'; import { CryptoUtils } from '../utils/crypto'; import { JWTUtils } from '../utils/jwt'; import { RegisterData, LoginCredentials, TokenPair, JWTPayload } from '../types'; import { Role, UserStatus } from '@prisma/client'; import { AppError } from '../middleware/error'; +import { NotificationService } from './notification.service'; +import { config } from '../config'; import logger from '../config/logger'; import { EmailPreferenceService } from './email-preference.service'; import crypto from 'crypto'; +const TOKEN_EXPIRY_HOURS = parseInt(process.env.VERIFICATION_TOKEN_EXPIRY_HOURS || '24', 10); +const RESEND_RATE_LIMIT = parseInt(process.env.VERIFICATION_RESEND_RATE_LIMIT || '3', 10); +const MAX_FAILED_ATTEMPTS = parseInt(process.env.VERIFICATION_MAX_FAILED_ATTEMPTS || '10', 10); + +function renderTemplate(filename: string, vars: Record): string { + const tplPath = path.join(__dirname, '../templates', filename); + let tpl = fs.readFileSync(tplPath, 'utf-8'); + for (const [key, value] of Object.entries(vars)) { + tpl = tpl.replaceAll(`{{${key}}}`, value); + } + return tpl; +} + +async function sendVerificationEmail(email: string, firstName: string, token: string): Promise { + const baseUrl = process.env.APP_BASE_URL || 'https://app.aidlink.org'; + const verificationLink = `${baseUrl}/verify-email?token=${token}`; + const year = new Date().getFullYear().toString(); + + const html = renderTemplate('verify-email.html', { firstName, verificationLink, year }); + const text = renderTemplate('verify-email.txt', { firstName, verificationLink, year }); + + await NotificationService.sendEmail(email, 'Verify your AidLink account', html, text); +} + +async function createVerificationLog( + userId: string, + action: string, + tokenHash?: string, + ipAddress?: string, + userAgent?: string +): Promise { + await prisma.verificationLog.create({ + data: { userId, action, tokenHash, ipAddress, userAgent }, + }); +} + export class AuthService { - static async register(data: RegisterData): Promise<{ user: any; tokens: TokenPair }> { + static async register( + data: RegisterData, + meta?: { ipAddress?: string; userAgent?: string } + ): Promise<{ userId: string; message: string }> { const { email, password, username, role = Role.DONOR } = data; + const normalizedEmail = email.toLowerCase(); - // Check if user already exists - const existingUser = await prisma.user.findUnique({ - where: { email }, - }); - - if (existingUser) { - throw new AppError('User with this email already exists', 409); - } + const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (existingUser) throw new AppError('User with this email already exists', 409); if (username) { - const existingUsername = await prisma.user.findUnique({ - where: { username }, - }); - - if (existingUsername) { - throw new AppError('Username already taken', 409); - } + const existingUsername = await prisma.user.findUnique({ where: { username } }); + if (existingUsername) throw new AppError('Username already taken', 409); } - // Hash password const passwordHash = await CryptoUtils.hashPassword(password); + const token = CryptoUtils.generateVerificationToken(); + const tokenHash = CryptoUtils.sha256(token); + const verificationExpiry = new Date(Date.now() + TOKEN_EXPIRY_HOURS * 60 * 60 * 1000); - // Create user const user = await prisma.user.create({ data: { - email, + email: normalizedEmail, passwordHash, username, role, status: UserStatus.PENDING_VERIFICATION, + verificationToken: tokenHash, + verificationExpiry, }, }); @@ -58,80 +95,151 @@ export class AuthService { // Generate tokens const tokens = this.generateTokens(user.id, user.email, user.role); - logger.info(`User registered: ${user.email}`); + // Send email async — don't block registration response + sendVerificationEmail(normalizedEmail, username || normalizedEmail.split('@')[0], token).catch( + (err) => logger.error('Failed to send verification email:', err) + ); - return { - user: this.sanitizeUser(user), - tokens, - }; + logger.info(`User registered: ${normalizedEmail}`); + + return { userId: user.id, message: 'User created. Check your email to verify.' }; } - static async login(credentials: LoginCredentials): Promise<{ user: any; tokens: TokenPair }> { - const { email, password } = credentials; + static async verifyEmail( + token: string, + meta?: { ipAddress?: string; userAgent?: string } + ): Promise { + const tokenHash = CryptoUtils.sha256(token); - // Find user - const user = await prisma.user.findUnique({ - where: { email }, - }); + const user = await prisma.user.findUnique({ where: { verificationToken: tokenHash } }); if (!user) { - throw new AppError('Invalid credentials', 401); + throw new AppError('Verification link expired or invalid.', 400); } - if (!user.passwordHash) { - throw new AppError('Please use wallet authentication', 400); + if (user.emailVerified) { + // Already verified — succeed gracefully + return; } - // Verify password - const isValidPassword = await CryptoUtils.comparePassword(password, user.passwordHash); + // Check failed attempts lockout + if (user.failedVerifyAttempts >= MAX_FAILED_ATTEMPTS) { + await createVerificationLog(user.id, 'FAILED', tokenHash, meta?.ipAddress, meta?.userAgent); + throw new AppError( + 'Too many failed verification attempts. Please request a new verification email.', + 429 + ); + } - if (!isValidPassword) { - throw new AppError('Invalid credentials', 401); + if (!user.verificationExpiry || user.verificationExpiry < new Date()) { + await prisma.user.update({ + where: { id: user.id }, + data: { failedVerifyAttempts: { increment: 1 } }, + }); + await createVerificationLog(user.id, 'EXPIRED', tokenHash, meta?.ipAddress, meta?.userAgent); + throw new AppError('Verification link expired or invalid.', 400); } - // Check user status - if (user.status === UserStatus.SUSPENDED) { - throw new AppError('Account suspended', 403); + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + status: UserStatus.ACTIVE, + verificationToken: null, + verificationExpiry: null, + failedVerifyAttempts: 0, + }, + }); + + await createVerificationLog(user.id, 'VERIFIED', tokenHash, meta?.ipAddress, meta?.userAgent); + + logger.info(`Email verified: ${user.email}`); + } + + static async resendVerificationEmail( + email: string, + meta?: { ipAddress?: string; userAgent?: string } + ): Promise<{ alreadyVerified?: boolean; message: string }> { + const normalizedEmail = email.toLowerCase(); + + const user = await prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user) { + // Don't reveal existence; respond as if sent + return { message: 'If that email is registered, a verification link has been sent.' }; } - if (user.status === UserStatus.DELETED) { - throw new AppError('Account deleted', 403); + if (user.emailVerified) { + return { alreadyVerified: true, message: 'Email is already verified.' }; } - // Update last login + // Redis-backed rate limit: max RESEND_RATE_LIMIT resends per hour per email + const rateLimitKey = `resend_verification:${normalizedEmail}`; + const count = await redis.incr(rateLimitKey); + if (count === 1) { + await redis.expire(rateLimitKey, 60 * 60); // 1 hour TTL + } + if (count > RESEND_RATE_LIMIT) { + throw new AppError('Too many resend attempts. Please try again later.', 429); + } + + const token = CryptoUtils.generateVerificationToken(); + const tokenHash = CryptoUtils.sha256(token); + const verificationExpiry = new Date(Date.now() + TOKEN_EXPIRY_HOURS * 60 * 60 * 1000); + await prisma.user.update({ where: { id: user.id }, - data: { lastLogin: new Date() }, + data: { verificationToken: tokenHash, verificationExpiry, failedVerifyAttempts: 0 }, }); - // Generate tokens - const tokens = this.generateTokens(user.id, user.email, user.role); + await createVerificationLog(user.id, 'RESENT', tokenHash, meta?.ipAddress, meta?.userAgent); + + sendVerificationEmail(normalizedEmail, user.username || normalizedEmail.split('@')[0], token).catch( + (err) => logger.error('Failed to send verification email:', err) + ); + + return { message: 'Verification email sent.' }; + } - // Create session + static async login(credentials: LoginCredentials): Promise<{ user: any; tokens: TokenPair }> { + const { email, password } = credentials; + const normalizedEmail = email.toLowerCase(); + + const user = await prisma.user.findUnique({ where: { email: normalizedEmail } }); + + if (!user || !user.passwordHash) { + throw new AppError('Invalid credentials', 401); + } + + const isValidPassword = await CryptoUtils.comparePassword(password, user.passwordHash); + if (!isValidPassword) throw new AppError('Invalid credentials', 401); + + if (user.status === UserStatus.SUSPENDED) throw new AppError('Account suspended', 403); + if (user.status === UserStatus.DELETED) throw new AppError('Account deleted', 403); + + if (!user.emailVerified) { + throw new AppError( + 'Please verify your email before logging in. Check your inbox or resend the verification email.', + 403 + ); + } + + await prisma.user.update({ where: { id: user.id }, data: { lastLogin: new Date() } }); + + const tokens = this.generateTokens(user.id, user.email, user.role); await this.createSession(user.id, tokens.accessToken, tokens.refreshToken); logger.info(`User logged in: ${user.email}`); - - return { - user: this.sanitizeUser(user), - tokens, - }; + return { user: this.sanitizeUser(user), tokens }; } static async walletAuth(walletAddress: string, signature: string, message: string): Promise<{ user: any; tokens: TokenPair }> { - // Verify signature (implementation depends on Stellar SDK) - // For now, we'll create/update user with wallet address - - let user = await prisma.user.findUnique({ - where: { walletAddress }, - }); + let user = await prisma.user.findUnique({ where: { walletAddress } }); if (!user) { - // Create new user with wallet user = await prisma.user.create({ data: { walletAddress, - email: `${walletAddress}@wallet.aidlink.org`, // Temporary email + email: `${walletAddress}@wallet.aidlink.org`, role: Role.DONOR, status: UserStatus.ACTIVE, emailVerified: true, @@ -144,55 +252,35 @@ export class AuthService { ); } - // Update last login - await prisma.user.update({ - where: { id: user.id }, - data: { lastLogin: new Date() }, - }); + await prisma.user.update({ where: { id: user.id }, data: { lastLogin: new Date() } }); - // Generate tokens const tokens = this.generateTokens(user.id, user.email, user.role); - - // Create session await this.createSession(user.id, tokens.accessToken, tokens.refreshToken); logger.info(`User authenticated via wallet: ${walletAddress}`); - - return { - user: this.sanitizeUser(user), - tokens, - }; + return { user: this.sanitizeUser(user), tokens }; } static async refreshToken(refreshToken: string): Promise { try { const payload = JWTUtils.verifyToken(refreshToken) as JWTPayload; - // Check if session exists - const session = await prisma.session.findUnique({ - where: { refreshToken }, - }); + const session = await prisma.session.findUnique({ where: { refreshToken } }); + if (!session) throw new AppError('Invalid refresh token', 401); - if (!session) { - throw new AppError('Invalid refresh token', 401); - } - - // Check if session is expired if (session.expiresAt < new Date()) { await prisma.session.delete({ where: { id: session.id } }); throw new AppError('Session expired', 401); } - // Generate new tokens const tokens = this.generateTokens(payload.id, payload.email, payload.role); - // Update session await prisma.session.update({ where: { id: session.id }, data: { token: tokens.accessToken, refreshToken: tokens.refreshToken, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); @@ -203,30 +291,18 @@ export class AuthService { } static async logout(userId: string, token: string): Promise { - await prisma.session.deleteMany({ - where: { userId, token }, - }); - + await prisma.session.deleteMany({ where: { userId, token } }); logger.info(`User logged out: ${userId}`); } static async logoutAll(userId: string): Promise { - await prisma.session.deleteMany({ - where: { userId }, - }); - + await prisma.session.deleteMany({ where: { userId } }); logger.info(`User logged out from all sessions: ${userId}`); } static async getUserById(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - throw new AppError('User not found', 404); - } - + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new AppError('User not found', 404); return this.sanitizeUser(user); } @@ -301,12 +377,7 @@ export class AuthService { // ── Private helpers ─────────────────────────────────────────────── private static generateTokens(userId: string, email: string, role: Role): TokenPair { - const payload: JWTPayload = { - id: userId, - email, - role, - }; - + const payload: JWTPayload = { id: userId, email, role }; return { accessToken: JWTUtils.generateAccessToken(payload), refreshToken: JWTUtils.generateRefreshToken(payload), @@ -319,13 +390,13 @@ export class AuthService { userId, token: accessToken, refreshToken, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); } - private static sanitizeUser(user: any): any { - const { passwordHash, ...sanitized } = user; + static sanitizeUser(user: any): any { + const { passwordHash, verificationToken, verificationExpiry, failedVerifyAttempts, ...sanitized } = user; return sanitized; } } \ No newline at end of file diff --git a/src/templates/verify-email.html b/src/templates/verify-email.html new file mode 100644 index 0000000..e2bac6e --- /dev/null +++ b/src/templates/verify-email.html @@ -0,0 +1,66 @@ + + + + + + Verify your AidLink account + + + + + + +
+ + + + + + + + + + + + + +
+

AidLink

+
+

Verify your email address

+

Hi {{firstName}},

+

+ Welcome to AidLink! To get started, please verify your email address by clicking the button below. +

+ + + + +
+ + Verify Email Address + +
+

+ Or copy and paste this link into your browser: +

+

+ {{verificationLink}} +

+

+ This link will expire in 24 hours. +

+

+ If you didn't create this account, you can safely ignore this email. +

+
+

+ Questions? Contact us at support@aidlink.org +

+

+ © {{year}} AidLink. All rights reserved. +

+
+
+ + diff --git a/src/templates/verify-email.txt b/src/templates/verify-email.txt new file mode 100644 index 0000000..919f2bb --- /dev/null +++ b/src/templates/verify-email.txt @@ -0,0 +1,16 @@ +Verify your AidLink account +=========================== + +Hi {{firstName}}, + +Welcome to AidLink! To get started, please verify your email address by visiting the link below: + +{{verificationLink}} + +This link will expire in 24 hours. + +If you didn't create this account, you can safely ignore this email. + +Questions? Contact us at support@aidlink.org + +© {{year}} AidLink. All rights reserved. diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 4615c7a..f9306a0 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -15,6 +15,11 @@ export class CryptoUtils { return crypto.randomBytes(length).toString('hex'); } + /** Generate a URL-safe verification token (32 bytes = 43 base64url chars) */ + static generateVerificationToken(): string { + return crypto.randomBytes(32).toString('base64url'); + } + static generateUUID(): string { return crypto.randomUUID(); } diff --git a/tests/integration/email.verification.flow.test.ts b/tests/integration/email.verification.flow.test.ts new file mode 100644 index 0000000..4a115ec --- /dev/null +++ b/tests/integration/email.verification.flow.test.ts @@ -0,0 +1,361 @@ +/** + * Integration tests for the email verification flow. + * + * Uses a stateful in-memory store (no live DB/Redis) to exercise the full + * AuthService lifecycle: register → verify-email → login, expired tokens, + * resend rate-limiting, and unverified-user restrictions. + */ +import { CryptoUtils } from '../../src/utils/crypto'; +import { Role, UserStatus } from '@prisma/client'; + +// ─── In-memory store ──────────────────────────────────────────────────────── +const store: { users: Map; vLogs: any[]; sessions: Map } = { + users: new Map(), + vLogs: [], + sessions: new Map(), +}; + +let idSeq = 0; +const nextId = () => `user-${++idSeq}`; + +// ─── Prisma fake ──────────────────────────────────────────────────────────── +const prismaFake: any = { + user: { + findUnique: jest.fn(async ({ where }: any) => { + if (where.email) return store.users.get(where.email) ?? null; + if (where.verificationToken) { + for (const u of store.users.values()) { + if (u.verificationToken === where.verificationToken) return { ...u }; + } + return null; + } + if (where.id) { + for (const u of store.users.values()) { + if (u.id === where.id) return { ...u }; + } + return null; + } + return null; + }), + create: jest.fn(async ({ data }: any) => { + const user = { id: nextId(), emailVerified: false, failedVerifyAttempts: 0, lastLogin: null, createdAt: new Date(), updatedAt: new Date(), ...data }; + store.users.set(user.email, user); + return { ...user }; + }), + update: jest.fn(async ({ where, data }: any) => { + let user: any = null; + for (const u of store.users.values()) { + if (u.id === where.id || u.email === where.email) { user = u; break; } + } + if (!user) throw new Error('User not found in fake'); + // Handle increment + for (const [k, v] of Object.entries(data)) { + if (v && typeof v === 'object' && 'increment' in v) { + user[k] = (user[k] ?? 0) + v.increment; + } else { + user[k] = v; + } + } + user.updatedAt = new Date(); + store.users.set(user.email, user); + return { ...user }; + }), + }, + verificationLog: { + create: jest.fn(async ({ data }: any) => { + const log = { id: `vlog-${store.vLogs.length + 1}`, createdAt: new Date(), ...data }; + store.vLogs.push(log); + return log; + }), + }, + session: { + create: jest.fn(async ({ data }: any) => { + const s = { id: `sess-${store.sessions.size + 1}`, createdAt: new Date(), ...data }; + store.sessions.set(data.token, s); + return s; + }), + findUnique: jest.fn(async ({ where }: any) => store.sessions.get(where.refreshToken) ?? null), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, +}; + +// ─── Redis fake ───────────────────────────────────────────────────────────── +const redisCounters: Record = {}; +const redisFake = { + incr: jest.fn(async (key: string) => { + redisCounters[key] = (redisCounters[key] ?? 0) + 1; + return redisCounters[key]; + }), + expire: jest.fn().mockResolvedValue(1), +}; + +// ─── Email spy ────────────────────────────────────────────────────────────── +const sentEmails: { to: string; subject: string; html: string; text?: string }[] = []; +const notificationFake = { + NotificationService: { + sendEmail: jest.fn(async (to: string, subject: string, html: string, text?: string) => { + sentEmails.push({ to, subject, html, text }); + }), + }, +}; + +// ─── Module mocks ──────────────────────────────────────────────────────────── +jest.mock('../../src/config/database', () => prismaFake); +jest.mock('../../src/config/redis', () => ({ __esModule: true, default: redisFake })); +jest.mock('../../src/services/notification.service', () => notificationFake); +jest.mock('../../src/config/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), error: jest.fn() }, +})); + +import { AuthService } from '../../src/services/auth.service'; +import { AppError } from '../../src/middleware/error'; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('Email Verification – Integration', () => { + beforeEach(() => { + store.users.clear(); + store.vLogs.length = 0; + store.sessions.clear(); + sentEmails.length = 0; + Object.keys(redisCounters).forEach((k) => delete redisCounters[k]); + jest.clearAllMocks(); + }); + + // ─── Full happy path ─────────────────────────────────────────────────────── + describe('Full registration → verify → login flow', () => { + it('registers user with emailVerified=false and sends verification email', async () => { + const result = await AuthService.register({ email: 'alice@example.com', password: 'pass123!!' }); + + expect(result.userId).toBeDefined(); + expect(result.message).toMatch(/verify/i); + + const user = store.users.get('alice@example.com')!; + expect(user.emailVerified).toBe(false); + expect(user.verificationToken).toMatch(/^[a-f0-9]{64}$/); // stored as SHA-256 hash + + // Email is sent asynchronously + await new Promise((r) => setImmediate(r)); + expect(sentEmails).toHaveLength(1); + expect(sentEmails[0].to).toBe('alice@example.com'); + expect(sentEmails[0].subject).toMatch(/verify/i); + }); + + it('extracts the plaintext token from email and verifies it', async () => { + await AuthService.register({ email: 'alice@example.com', password: 'pass123!!' }); + await new Promise((r) => setImmediate(r)); + + const emailHtml = sentEmails[0].html; + const tokenMatch = emailHtml.match(/token=([A-Za-z0-9_-]+)/); + expect(tokenMatch).toBeTruthy(); + const plaintextToken = tokenMatch![1]; + + await AuthService.verifyEmail(plaintextToken); + + const user = store.users.get('alice@example.com')!; + expect(user.emailVerified).toBe(true); + expect(user.verificationToken).toBeNull(); + expect(user.verificationExpiry).toBeNull(); + }); + + it('allows login after verification and rejects before', async () => { + await AuthService.register({ email: 'alice@example.com', password: 'pass123!!' }); + + // Login before verification should fail + await expect( + AuthService.login({ email: 'alice@example.com', password: 'pass123!!' }) + ).rejects.toThrow(expect.objectContaining({ statusCode: 403 })); + + // Verify + await new Promise((r) => setImmediate(r)); + const emailHtml = sentEmails[0].html; + const token = emailHtml.match(/token=([A-Za-z0-9_-]+)/)![1]; + await AuthService.verifyEmail(token); + + // Update status in fake store manually (service sets ACTIVE on verify) + const user = store.users.get('alice@example.com')!; + expect(user.emailVerified).toBe(true); + + // Login after verification should succeed + const loginResult = await AuthService.login({ email: 'alice@example.com', password: 'pass123!!' }); + expect(loginResult.tokens.accessToken).toBeDefined(); + }); + + it('creates a VerificationLog entry with action=SENT on register', async () => { + await AuthService.register({ email: 'alice@example.com', password: 'pass123!!' }); + + expect(store.vLogs.some((l) => l.action === 'SENT')).toBe(true); + }); + + it('creates a VerificationLog entry with action=VERIFIED on verification', async () => { + await AuthService.register({ email: 'alice@example.com', password: 'pass123!!' }); + await new Promise((r) => setImmediate(r)); + + const token = sentEmails[0].html.match(/token=([A-Za-z0-9_-]+)/)![1]; + await AuthService.verifyEmail(token); + + expect(store.vLogs.some((l) => l.action === 'VERIFIED')).toBe(true); + }); + }); + + // ─── Expired token ───────────────────────────────────────────────────────── + describe('Expired token handling', () => { + it('returns 400 for expired token and suggests resend', async () => { + // Manually create a user with past expiry + const tokenHash = CryptoUtils.sha256('old-token'); + const user = { + id: nextId(), + email: 'bob@example.com', + username: 'bob', + passwordHash: await CryptoUtils.hashPassword('pass123!!'), + role: Role.DONOR, + status: UserStatus.PENDING_VERIFICATION, + emailVerified: false, + verificationToken: tokenHash, + verificationExpiry: new Date(Date.now() - 1000), // 1 second ago + failedVerifyAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.users.set(user.email, user); + + prismaFake.verificationLog.create.mockClear(); + + await expect(AuthService.verifyEmail('old-token')).rejects.toThrow( + expect.objectContaining({ statusCode: 400 }) + ); + + // Failed attempts incremented + expect(store.users.get('bob@example.com')!.failedVerifyAttempts).toBe(1); + // Log entry with EXPIRED + expect(store.vLogs.some((l) => l.action === 'EXPIRED')).toBe(true); + }); + + it('allows resend after expiry and new token replaces old', async () => { + await AuthService.register({ email: 'bob@example.com', password: 'pass123!!' }); + redisFake.incr.mockResolvedValueOnce(1); + + // Expire the token + const user = store.users.get('bob@example.com')!; + user.verificationExpiry = new Date(Date.now() - 1000); + + sentEmails.length = 0; + await AuthService.resendVerificationEmail('bob@example.com'); + await new Promise((r) => setImmediate(r)); + + expect(sentEmails).toHaveLength(1); + + const newTokenHash = store.users.get('bob@example.com')!.verificationToken; + expect(newTokenHash).toMatch(/^[a-f0-9]{64}$/); + + // Old token should no longer work (hash changed); new token works + const newToken = sentEmails[0].html.match(/token=([A-Za-z0-9_-]+)/)![1]; + await AuthService.verifyEmail(newToken); + expect(store.users.get('bob@example.com')!.emailVerified).toBe(true); + }); + }); + + // ─── Resend rate limiting ────────────────────────────────────────────────── + describe('Resend rate limiting', () => { + it('allows up to RESEND_RATE_LIMIT (3) resends per hour', async () => { + await AuthService.register({ email: 'carol@example.com', password: 'pass123!!' }); + + redisFake.incr + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(3); + + for (let i = 0; i < 3; i++) { + await expect( + AuthService.resendVerificationEmail('carol@example.com') + ).resolves.toBeDefined(); + } + }); + + it('throws 429 on the 4th resend attempt', async () => { + await AuthService.register({ email: 'carol@example.com', password: 'pass123!!' }); + + redisFake.incr.mockResolvedValue(4); // over limit + + await expect( + AuthService.resendVerificationEmail('carol@example.com') + ).rejects.toThrow(expect.objectContaining({ statusCode: 429 })); + }); + }); + + // ─── Security ───────────────────────────────────────────────────────────── + describe('Security properties', () => { + it('token is not exposed in any API response', async () => { + const result = await AuthService.register({ email: 'dave@example.com', password: 'pass123!!' }); + const user = store.users.get('dave@example.com')!; + const storedHash = user.verificationToken; + + expect(JSON.stringify(result)).not.toContain(storedHash); + }); + + it('reusing a token after verification returns gracefully (already verified)', async () => { + await AuthService.register({ email: 'eve@example.com', password: 'pass123!!' }); + await new Promise((r) => setImmediate(r)); + + const token = sentEmails[0].html.match(/token=([A-Za-z0-9_-]+)/)![1]; + await AuthService.verifyEmail(token); + + // Try the same token again — user is verified, token is cleared + // findUnique by verificationToken will return null now + await expect(AuthService.verifyEmail(token)).rejects.toThrow(AppError); + }); + + it('different resend generates different token (old token invalidated)', async () => { + await AuthService.register({ email: 'frank@example.com', password: 'pass123!!' }); + const firstHash = store.users.get('frank@example.com')!.verificationToken; + + redisFake.incr.mockResolvedValueOnce(1); + await AuthService.resendVerificationEmail('frank@example.com'); + const secondHash = store.users.get('frank@example.com')!.verificationToken; + + expect(firstHash).not.toBe(secondHash); + }); + }); + + // ─── Email delivery content ─────────────────────────────────────────────── + describe('Email delivery', () => { + it('email contains the verification link with token', async () => { + await AuthService.register({ email: 'grace@example.com', password: 'pass123!!' }); + await new Promise((r) => setImmediate(r)); + + expect(sentEmails[0].html).toMatch(/verify-email\?token=/); + expect(sentEmails[0].text).toMatch(/verify-email\?token=/); + }); + + it('email is sent on resend with fresh link', async () => { + await AuthService.register({ email: 'henry@example.com', password: 'pass123!!' }); + sentEmails.length = 0; + + redisFake.incr.mockResolvedValueOnce(1); + await AuthService.resendVerificationEmail('henry@example.com'); + await new Promise((r) => setImmediate(r)); + + expect(sentEmails).toHaveLength(1); + expect(sentEmails[0].to).toBe('henry@example.com'); + }); + + it('email is NOT sent for already-verified user on resend', async () => { + store.users.set('verified@example.com', { + id: nextId(), + email: 'verified@example.com', + emailVerified: true, + failedVerifyAttempts: 0, + verificationToken: null, + verificationExpiry: null, + }); + + await AuthService.resendVerificationEmail('verified@example.com'); + await new Promise((r) => setImmediate(r)); + + expect(sentEmails).toHaveLength(0); + }); + }); +});