Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = {
'',
'^@/validators(/.*)?$',
'^@/utils(/.*)?$',
'^@/constants(/.*)?$',
'',
'^@tests(/.*)?$',
'',
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions src/interfaces/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface OtpData {
hashedOtp: string;
email: string;
createdAt: number;
ulid: string;
}

export interface IOtpService {
sendOTPForVerification(email: string): Promise<string>;
verify(email: string, otp: string, ulid: string): Promise<boolean>;
}
108 changes: 108 additions & 0 deletions src/plugin/otp.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 = `
<h2>Your OTP Code</h2>
<p>Your one-time password is: <strong>${otp}</strong></p>
<p>This code will expire in 10 minutes.</p>
`;

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<boolean> {
try {
const cacheKey = getOtpCacheKey(ulid, email);
const otpData = await fastify.cache.get<OtpData>(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',
},
);
6 changes: 3 additions & 3 deletions src/routes/auth/change-password.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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,
);
Expand All @@ -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;`;
Expand Down
4 changes: 2 additions & 2 deletions src/routes/auth/login.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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
Expand Down
13 changes: 2 additions & 11 deletions src/routes/auth/registration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import bcrypt from 'bcrypt';
import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';

import {
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions src/utils/hash.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<boolean> {
return bcrypt.compare(data, encrypted);
}
Loading
Loading