diff --git a/.prettierrc.cjs b/.prettierrc.cjs index 1dfbf5d..c306f4a 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -17,6 +17,7 @@ module.exports = { '', '^@/validators(/.*)?$', '^@/utils(/.*)?$', + '^@/constants(/.*)?$', '', '^@tests(/.*)?$', '', diff --git a/package-lock.json b/package-lock.json index 934ed94..a72424e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "node-cache": "^5.1.2", - "pg": "^8.20.0" + "pg": "^8.20.0", + "ulid": "^2.3.0" }, "bin": { "ross": "build/src/main.js" @@ -8891,6 +8892,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/ulid": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", + "integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==", + "license": "MIT", + "bin": { + "ulid": "bin/cli.js" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index e5e3ef8..dbe2d05 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "node-cache": "^5.1.2", - "pg": "^8.20.0" + "pg": "^8.20.0", + "ulid": "^2.3.0" } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..815ced2 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,6 @@ +/** + * Authentication and OTP constants. + */ +export const BCRYPT_SALT_ROUNDS = 10; +export const OTP_TTL_SECONDS = 600; // 10 minutes +export const OTP_LENGTH = 6; diff --git a/src/interfaces/otp.ts b/src/interfaces/otp.ts new file mode 100644 index 0000000..cf32b4e --- /dev/null +++ b/src/interfaces/otp.ts @@ -0,0 +1,11 @@ +export interface OtpData { + hashedOtp: string; + email: string; + createdAt: number; + ulid: string; +} + +export interface IOtpService { + sendOTPForVerification(email: string): Promise; + verify(email: string, otp: string, ulid: string): Promise; +} diff --git a/src/plugin/otp.ts b/src/plugin/otp.ts new file mode 100644 index 0000000..d95fac0 --- /dev/null +++ b/src/plugin/otp.ts @@ -0,0 +1,108 @@ +import crypto from 'crypto'; + +import {FastifyInstance} from 'fastify'; +import fp from 'fastify-plugin'; +import {ulid} from 'ulid'; + +import {IOtpService, OtpData} from '@/interfaces/otp'; + +import {compare, hash} from '@/utils/hash'; +import {OTP_LENGTH, OTP_TTL_SECONDS} from '@/constants'; + +/** + * Get the cache key for an OTP + */ +function getOtpCacheKey(ulid: string, email: string): string { + return `otp:${ulid}:${email}`; +} + +/** + * Generate a cryptographically secure random OTP of specified length (default 6 digits) + */ +function generateOtp(length: number = OTP_LENGTH): string { + let otp = ''; + for (let i = 0; i < length; i++) { + otp += crypto.randomInt(0, 10).toString(); + } + return otp; +} + +export default fp( + async (fastify: FastifyInstance) => { + const otpService: IOtpService = { + /** + * Generate OTP, hash it, store in cache, and send via email + */ + async sendOTPForVerification(email: string): Promise { + try { + const otp = generateOtp(); + const hashedOtp = await hash(otp); + const otpUlid = ulid(); + + const otpData: OtpData = { + hashedOtp, + email, + createdAt: Date.now(), + ulid: otpUlid, + }; + + const cacheKey = getOtpCacheKey(otpUlid, email); + await fastify.cache.set(cacheKey, otpData, OTP_TTL_SECONDS); + + // Send email with OTP + const htmlBody = ` +

Your OTP Code

+

Your one-time password is: ${otp}

+

This code will expire in 10 minutes.

+ `; + + const body = `Your one-time password is: ${otp}\nThis code will expire in 10 minutes.`; + + await fastify.communicate.sendEmail(email, htmlBody, body); + + fastify.log.info(`OTP sent to email: ${email}`); + return otpUlid; + } catch (err) { + fastify.log.error(`Failed to send OTP for email ${email}: ${err}`); + throw err; + } + }, + + /** + * Verify OTP and remove from cache on success + */ + async verify(email: string, otp: string, ulid: string): Promise { + try { + const cacheKey = getOtpCacheKey(ulid, email); + const otpData = await fastify.cache.get(cacheKey); + + if (!otpData) { + fastify.log.warn(`OTP not found or expired for email: ${email}`); + return false; + } + + // Compare provided OTP with stored hash + const isValid = await compare(otp, otpData.hashedOtp); + + if (isValid) { + // Delete OTP from cache after successful verification + await fastify.cache.delete(cacheKey); + fastify.log.info(`OTP verified successfully for email: ${email}`); + return true; + } else { + fastify.log.warn(`Invalid OTP provided for email: ${email}`); + return false; + } + } catch (err) { + fastify.log.error(`Error verifying OTP for email ${email}: ${err}`); + throw err; + } + }, + }; + + fastify.decorate('otp', otpService); + }, + { + name: 'otp-plugin', + }, +); diff --git a/src/routes/auth/change-password.ts b/src/routes/auth/change-password.ts index 94809bb..79e380d 100644 --- a/src/routes/auth/change-password.ts +++ b/src/routes/auth/change-password.ts @@ -1,10 +1,10 @@ -import bcrypt from 'bcrypt'; import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; import {getResponseStructureSchema} from '@/routes/schema-helpers'; import {AppConfig} from '@/interfaces/config'; +import {compare, hash} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerChangePasswordRoute( @@ -83,7 +83,7 @@ export function registerChangePasswordRoute( const currentHashedPassword = user[passwordColumn] as string; // Verify existing password - const isMatch = await bcrypt.compare( + const isMatch = await compare( String(existingPassword), currentHashedPassword, ); @@ -94,7 +94,7 @@ export function registerChangePasswordRoute( } // Hash the new password - const newHashedPassword = await bcrypt.hash(String(newPassword), 10); + const newHashedPassword = await hash(String(newPassword)); // Update the password const updateQuery = `UPDATE "${modelName}" SET "${passwordColumn}" = $1 WHERE "${idColumn}" = $2;`; diff --git a/src/routes/auth/login.ts b/src/routes/auth/login.ts index 70f02af..14b8107 100644 --- a/src/routes/auth/login.ts +++ b/src/routes/auth/login.ts @@ -1,10 +1,10 @@ -import bcrypt from 'bcrypt'; import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; import {getResponseStructureSchema} from '@/routes/schema-helpers'; import {AppConfig, ModelBody} from '@/interfaces/config'; +import {compare} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; /** @@ -72,7 +72,7 @@ export function registerLoginRoute( const hashedPassword = user[passwordColumn] as string; // Compare passwords - const isMatch = await bcrypt.compare(String(password), hashedPassword); + const isMatch = await compare(String(password), hashedPassword); if (!isMatch) { return reply diff --git a/src/routes/auth/registration.ts b/src/routes/auth/registration.ts index 88e71fc..15d9a69 100644 --- a/src/routes/auth/registration.ts +++ b/src/routes/auth/registration.ts @@ -1,4 +1,3 @@ -import bcrypt from 'bcrypt'; import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; import { @@ -9,14 +8,9 @@ import { import {AppConfig, ModelBody, ModelConfig} from '@/interfaces/config'; +import {hash} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; -/** - * The number of bcrypt salt rounds used when hashing passwords. - * 10 is a widely accepted default that balances security and performance. - */ -const BCRYPT_SALT_ROUNDS = 10; - /** * Register the POST /auth/register route. * @@ -87,10 +81,7 @@ export function registerRegistrationRoute( // rejected the request. if (body[passwordColumn] !== undefined && body[passwordColumn] !== null) { const rawPassword = String(body[passwordColumn]); - body[passwordColumn] = await bcrypt.hash( - rawPassword, - BCRYPT_SALT_ROUNDS, - ); + body[passwordColumn] = await hash(rawPassword); } // Build the INSERT statement dynamically from the sanitised body keys. diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 0000000..e52e7a9 --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,29 @@ +import bcrypt from 'bcrypt'; + +import {BCRYPT_SALT_ROUNDS} from '@/constants'; + +/** + * Hashes plain text data using bcrypt. + * @param data The plain text data to hash. + * @param saltRounds The number of salt rounds to use (defaults to BCRYPT_SALT_ROUNDS from constants). + * @returns A promise that resolves to the hashed string. + */ +export async function hash( + data: string, + saltRounds = BCRYPT_SALT_ROUNDS, +): Promise { + return bcrypt.hash(data, saltRounds); +} + +/** + * Compares plain text data with a bcrypt hash. + * @param data The plain text data to check. + * @param encrypted The encrypted hash to compare against. + * @returns A promise that resolves to true if the data matches the hash, false otherwise. + */ +export async function compare( + data: string, + encrypted: string, +): Promise { + return bcrypt.compare(data, encrypted); +} diff --git a/tests/plugin/otp.test.ts b/tests/plugin/otp.test.ts new file mode 100644 index 0000000..abd334d --- /dev/null +++ b/tests/plugin/otp.test.ts @@ -0,0 +1,349 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; + +import otpPlugin from '@/plugin/otp'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface MockCache { + get: Mock; + set: Mock; + delete: Mock; + has: Mock; + clear: Mock; + raw: any; +} + +interface MockCommunicate { + sendEmail: Mock; +} + +describe('OTP Plugin', () => { + let app: FastifyInstance; + let cacheMock: MockCache; + let communicateMock: MockCommunicate; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock cache storage + const cacheStorage = new Map(); + + cacheMock = { + get: vi.fn(async (key: string) => { + const item = cacheStorage.get(key); + if (!item) return null; + if (item.expiry && item.expiry < Date.now()) { + cacheStorage.delete(key); + return null; + } + return item.value; + }), + set: vi.fn(async (key: string, value: unknown, ttlSeconds?: number) => { + const expiry = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined; + cacheStorage.set(key, {value, expiry}); + }), + delete: vi.fn(async (key: string) => { + cacheStorage.delete(key); + }), + has: vi.fn(async (key: string) => { + return cacheStorage.has(key); + }), + clear: vi.fn(async () => { + cacheStorage.clear(); + }), + raw: {} as any, + }; + + communicateMock = { + sendEmail: vi.fn(async () => {}), + }; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('Plugin Registration', () => { + it('should decorate fastify with otp service', async () => { + app = Fastify(); + (app as any).cache = cacheMock; + (app as any).communicate = communicateMock; + + await app.register(otpPlugin); + await app.ready(); + + expect(app.hasDecorator('otp')).toBe(true); + expect((app as any).otp).toBeDefined(); + expect(typeof (app as any).otp.sendOTPForVerification).toBe('function'); + expect(typeof (app as any).otp.verify).toBe('function'); + }); + }); + + describe('sendOTPForVerification', () => { + beforeEach(async () => { + app = Fastify(); + (app as any).cache = cacheMock; + (app as any).communicate = communicateMock; + await app.register(otpPlugin); + await app.ready(); + }); + + it('should generate and hash OTP, store in cache, and send email', async () => { + const email = 'test@example.com'; + + const ulid = await (app as any).otp.sendOTPForVerification(email); + + // Verify ulid is returned + expect(ulid).toBeDefined(); + expect(typeof ulid).toBe('string'); + expect(ulid.length).toBe(26); // ULID length + + // Verify cache.set was called with correct cache key + expect(cacheMock.set).toHaveBeenCalledWith( + `otp:${ulid}:${email}`, + expect.objectContaining({ + email, + hashedOtp: expect.any(String), + createdAt: expect.any(Number), + ulid, + }), + 600, // 10 minutes TTL + ); + + // Verify email was sent + expect(communicateMock.sendEmail).toHaveBeenCalledWith( + email, + expect.stringContaining('Your OTP Code'), + expect.stringContaining('one-time password'), + ); + }); + + it('should send OTP with proper email format', async () => { + const email = 'user@domain.com'; + + await (app as any).otp.sendOTPForVerification(email); + + const [sentEmail, htmlBody, textBody] = ( + communicateMock.sendEmail as Mock + ).mock.calls[0]; + + expect(sentEmail).toBe(email); + expect(htmlBody).toContain('

Your OTP Code

'); + expect(htmlBody).toContain('expire in 10 minutes'); + expect(textBody).toContain('one-time password'); + expect(textBody).toContain('expire in 10 minutes'); + }); + + it('should generate 6-digit OTP', async () => { + const email = 'test@example.com'; + + // We'll verify indirectly by checking the email content + await (app as any).otp.sendOTPForVerification(email); + + const [, htmlBody] = (communicateMock.sendEmail as Mock).mock.calls[0]; + + // Extract OTP from HTML body (format: XXXXXX) + const otpMatch = htmlBody.match(/(\d{6})<\/strong>/); + expect(otpMatch).not.toBeNull(); + expect(otpMatch![1]).toMatch(/^\d{6}$/); + }); + + it('should handle multiple OTPs for different emails', async () => { + const email1 = 'user1@example.com'; + const email2 = 'user2@example.com'; + + const ulid1 = await (app as any).otp.sendOTPForVerification(email1); + const ulid2 = await (app as any).otp.sendOTPForVerification(email2); + + expect(cacheMock.set).toHaveBeenCalledTimes(2); + expect(cacheMock.set).toHaveBeenCalledWith( + `otp:${ulid1}:${email1}`, + expect.any(Object), + 600, + ); + expect(cacheMock.set).toHaveBeenCalledWith( + `otp:${ulid2}:${email2}`, + expect.any(Object), + 600, + ); + }); + }); + + describe('verify', () => { + beforeEach(async () => { + app = Fastify(); + (app as any).cache = cacheMock; + (app as any).communicate = communicateMock; + await app.register(otpPlugin); + await app.ready(); + }); + + it('should successfully verify correct OTP and delete cache entry', async () => { + const email = 'test@example.com'; + + // First, send OTP + const ulid = await (app as any).otp.sendOTPForVerification(email); + + // Extract the OTP from the email body to verify it + const [, htmlBody] = (communicateMock.sendEmail as Mock).mock.calls[0]; + const otpMatch = htmlBody.match(/(\d{6})<\/strong>/); + const correctOtp = otpMatch![1]; + + // Now verify with correct OTP and ulid + const result = await (app as any).otp.verify(email, correctOtp, ulid); + + expect(result).toBe(true); + expect(cacheMock.delete).toHaveBeenCalledWith(`otp:${ulid}:${email}`); + }); + + it('should return false for incorrect OTP', async () => { + const email = 'test@example.com'; + + // Send OTP + const ulid = await (app as any).otp.sendOTPForVerification(email); + + // Try to verify with wrong OTP + const result = await (app as any).otp.verify(email, '000000', ulid); + + expect(result).toBe(false); + // Cache should NOT be deleted for failed verification + expect(cacheMock.delete).not.toHaveBeenCalled(); + }); + + it('should return false when OTP not found in cache', async () => { + const email = 'nonexistent@example.com'; + const fakeUlid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + const result = await (app as any).otp.verify(email, '123456', fakeUlid); + + expect(result).toBe(false); + expect(cacheMock.get).toHaveBeenCalledWith(`otp:${fakeUlid}:${email}`); + }); + + it('should return false for expired OTP', async () => { + const email = 'test@example.com'; + const fakeUlid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + // Simulate cache returning null for expired entry + (cacheMock.get as Mock).mockResolvedValueOnce(null); + + const result = await (app as any).otp.verify(email, '123456', fakeUlid); + + expect(result).toBe(false); + }); + + it('should verify multiple OTPs independently', async () => { + const email1 = 'user1@example.com'; + const email2 = 'user2@example.com'; + + // Send OTPs to both emails + const ulid1 = await (app as any).otp.sendOTPForVerification(email1); + const ulid2 = await (app as any).otp.sendOTPForVerification(email2); + + // Get OTPs from email bodies + const calls = (communicateMock.sendEmail as Mock).mock.calls; + const otp1Match = calls[0][1].match(/(\d{6})<\/strong>/); + const otp2Match = calls[1][1].match(/(\d{6})<\/strong>/); + + const otp1 = otp1Match![1]; + const otp2 = otp2Match![1]; + + // Reset mocks to get fresh tracking + vi.clearAllMocks(); + + // Verify first OTP + const result1 = await (app as any).otp.verify(email1, otp1, ulid1); + expect(result1).toBe(true); + + // Verify second OTP + const result2 = await (app as any).otp.verify(email2, otp2, ulid2); + expect(result2).toBe(true); + + // Both should have deleted their cache entries + expect(cacheMock.delete).toHaveBeenCalledWith(`otp:${ulid1}:${email1}`); + expect(cacheMock.delete).toHaveBeenCalledWith(`otp:${ulid2}:${email2}`); + }); + }); + + describe('Error Handling', () => { + beforeEach(async () => { + app = Fastify(); + (app as any).cache = cacheMock; + (app as any).communicate = communicateMock; + await app.register(otpPlugin); + await app.ready(); + }); + + it('should handle cache set errors in sendOTPForVerification', async () => { + const email = 'test@example.com'; + const error = new Error('Cache error'); + + (cacheMock.set as Mock).mockRejectedValueOnce(error); + + await expect( + (app as any).otp.sendOTPForVerification(email), + ).rejects.toThrow('Cache error'); + }); + + it('should handle email send errors in sendOTPForVerification', async () => { + const email = 'test@example.com'; + const error = new Error('Email send failed'); + + (communicateMock.sendEmail as Mock).mockRejectedValueOnce(error); + + await expect( + (app as any).otp.sendOTPForVerification(email), + ).rejects.toThrow('Email send failed'); + }); + + it('should handle cache get errors in verify', async () => { + const email = 'test@example.com'; + const fakeUlid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + const error = new Error('Cache error'); + + (cacheMock.get as Mock).mockRejectedValueOnce(error); + + await expect( + (app as any).otp.verify(email, '123456', fakeUlid), + ).rejects.toThrow('Cache error'); + }); + }); + + describe('Integration Tests', () => { + beforeEach(async () => { + app = Fastify(); + (app as any).cache = cacheMock; + (app as any).communicate = communicateMock; + await app.register(otpPlugin); + await app.ready(); + }); + + it('should complete full OTP flow: send and verify', async () => { + const email = 'integration@example.com'; + + // Step 1: Send OTP + const ulid = await (app as any).otp.sendOTPForVerification(email); + expect(communicateMock.sendEmail).toHaveBeenCalled(); + expect(ulid).toBeDefined(); + + // Step 2: Extract OTP from email + const [, htmlBody] = (communicateMock.sendEmail as Mock).mock.calls[0]; + const otpMatch = htmlBody.match(/(\d{6})<\/strong>/); + const otp = otpMatch![1]; + + // Step 3: Verify OTP with ulid + const isValid = await (app as any).otp.verify(email, otp, ulid); + expect(isValid).toBe(true); + + // Step 4: Verify cache was deleted with the new cache key format + expect(cacheMock.delete).toHaveBeenCalledWith(`otp:${ulid}:${email}`); + + // Step 5: Trying to verify again should fail + const secondVerify = await (app as any).otp.verify(email, otp, ulid); + expect(secondVerify).toBe(false); + }); + }); +}); diff --git a/tests/utils/hash.test.ts b/tests/utils/hash.test.ts new file mode 100644 index 0000000..6de36da --- /dev/null +++ b/tests/utils/hash.test.ts @@ -0,0 +1,55 @@ +import {describe, expect, test} from 'vitest'; + +import {compare, hash} from '@/utils/hash'; + +describe('hash utility', () => { + test('should successfully hash a plain text string', async () => { + const plainText = 'my-secret-password'; + const hashed = await hash(plainText); + + expect(hashed).toBeDefined(); + expect(typeof hashed).toBe('string'); + expect(hashed).not.toBe(plainText); + expect(hashed.length).toBeGreaterThan(0); + // bcrypt hash format: starts with $2a$, $2b$, or $2y$ followed by cost parameters and hash + expect(hashed).toMatch(/^\$2[aby]\$\d+\$/); + }); + + test('should correctly compare matching data', async () => { + const plainText = 'another-secure-password'; + const hashed = await hash(plainText); + + const isMatch = await compare(plainText, hashed); + expect(isMatch).toBe(true); + }); + + test('should return false when comparing non-matching data', async () => { + const plainText = 'correct-password'; + const wrongText = 'wrong-password'; + const hashed = await hash(plainText); + + const isMatch = await compare(wrongText, hashed); + expect(isMatch).toBe(false); + }); + + test('should handle empty strings correctly', async () => { + const emptyString = ''; + const hashed = await hash(emptyString); + + expect(hashed).toMatch(/^\$2[aby]\$\d+\$/); + const isMatch = await compare(emptyString, hashed); + expect(isMatch).toBe(true); + + const isNotMatch = await compare('some-text', hashed); + expect(isNotMatch).toBe(false); + }); + + test('should respect custom salt rounds', async () => { + const plainText = 'custom-salt-rounds'; + const lowSaltHashed = await hash(plainText, 4); + + expect(lowSaltHashed).toMatch(/^\$2[aby]\$04\$/); + const isMatch = await compare(plainText, lowSaltHashed); + expect(isMatch).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e07deb1..d267b75 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ }, "include": [ "src/**/*.ts", - "tests/**/*.ts" + "tests/**/*.ts", + "src/constants.ts" ], "tsc-alias": { "resolveFullPaths": true