diff --git a/api.md b/api.md index abe8075..2ac9c74 100644 --- a/api.md +++ b/api.md @@ -587,7 +587,6 @@ Trigger a mailing list sync with ListMonk for a particular user. } ``` - ### POST - Verify Mailing List `/api/action/verifyMailingList` @@ -1027,6 +1026,118 @@ Remove the given check in notes to a user's check in notes. Returns all the user } ``` +### POST - Generate OTP (Organizer) + +`/api/action/generate-otp` + +Generate a 6-digit OTP code for a volunteer (external user). The OTP expires in 10 minutes and can only be used once. + +#### Input Specification + +``` +{ + email: "" +} +``` + +#### Output Specification + +``` +{ + status: 200, + message: { + success: true, + message: "OTP generated successfully", + code: "123456", + expiration: "2024-01-01T12:00:00.000Z" + } +} +``` + +### POST - Verify OTP (Organizer) + +`/api/action/verify-otp` + +Verify a 6-digit OTP code for a volunteer. If valid, the OTP is marked as used and cannot be reused. Returns a JWT token for authentication. + +#### Input Specification + +``` +{ + code: "123456", + email: "" +} +``` + +#### Output Specification + +``` +{ + status: 200, + message: { + success: true, + message: "OTP verified successfully", + user: { + // External user object + }, + token: "" + } +} +``` + +### GET - Get All OTPs (Organizer) + +`/api/action/get-all-otps` + +Retrieve all OTP codes with their usage tracking information. + +#### Output Specification + +``` +{ + status: 200, + message: { + success: true, + otps: [ + { + id: "", + code: "123456", + email: "", + used: true, + expiration: "2024-01-01T12:00:00.000Z", + createdAt: "2024-01-01T11:50:00.000Z", + usedBy: "", + usedAt: "2024-01-01T11:55:00.000Z", + issuedBy: "", + usedName: "John Doe" + } + ] + } +} +``` + +### DELETE - Expire OTP (Organizer) + +`/api/action/expire-otp/:otpId` + +Expire a specific OTP code by setting its expiration time to the current time. + +#### Input Specification + +`otpId` - The ID of the OTP to expire (passed in URL) + +#### Output Specification + +``` +{ + status: 200, + message: { + success: true, + message: "OTP expired successfully" + } +} +``` + ### GET - Check in QR Code `/api/action/checkInQR` diff --git a/src/controller/OTPController.ts b/src/controller/OTPController.ts new file mode 100644 index 0000000..935886a --- /dev/null +++ b/src/controller/OTPController.ts @@ -0,0 +1,113 @@ +import { IUser } from '../models/user/fields'; +import OTP from '../models/otp/OTP'; +import ExternalUser from '../models/externaluser/ExternalUser'; +import { BadRequestError, NotFoundError } from '../types/errors'; +import { createJwt } from '../services/permissions'; + +export async function generateOTP(requestUser: IUser, email: string) { + const externalUser = await ExternalUser.findOne({ email }); + if (!externalUser) { + throw new BadRequestError('Email not found in external users'); + } + + const code = Math.floor(100000 + Math.random() * 900000).toString(); + const expiration = Date.now() + 10 * 60 * 1000; + + const otp = new OTP({ + code, + email, + expiration, + used: false, + issuedBy: requestUser.email, + }); + + await otp.save(); + + return { + success: true, + message: 'OTP generated successfully', + code, + expiration: new Date(expiration), + }; +} + +export async function verifyOTP( + requestUser: IUser | null, + code: string, + email: string, +) { + const externalUser = await ExternalUser.findOne({ email }); + if (!externalUser) { + throw new BadRequestError('Email not found in external users'); + } + + console.log('OTP.findOne', code, email); + const otp = await OTP.findOne({ code, email }); + if (!otp) { + throw new NotFoundError('Invalid OTP code'); + } + + if (otp.used) { + throw new BadRequestError('OTP code already used'); + } + + if (Date.now() > otp.expiration) { + throw new BadRequestError('OTP code expired'); + } + + otp.used = true; + otp.usedBy = externalUser._id.toString(); + otp.usedAt = new Date(); + otp.usedName = externalUser.firstName + ' ' + externalUser.lastName; + await otp.save(); + + const token = createJwt({ + id: externalUser._id, + idpLinkID: `OTP-${externalUser._id}`, + roles: { + volunteer: true, + }, + }); + + return { + success: true, + message: 'OTP verified successfully', + user: externalUser, + token: token, + }; +} + +export async function getAllOTPs(requestUser: IUser) { + const otps = await OTP.find({}).sort({ createdAt: -1 }); + + return { + success: true, + otps: otps.map((otp) => ({ + id: otp._id, + code: otp.code, + email: otp.email, + used: otp.used, + expiration: new Date(otp.expiration), + createdAt: otp.createdAt, + usedBy: otp.usedBy, + usedAt: otp.usedAt, + issuedBy: otp.issuedBy, + usedName: otp.usedName, + })), + }; +} + +export async function expireOTP(requestUser: IUser, otpId: string) { + const otp = await OTP.findById(otpId); + if (!otp) { + throw new NotFoundError('OTP not found'); + } + + otp.expiration = Date.now(); + await otp.save(); + + return { + success: true, + message: 'OTP expired successfully', + }; +} diff --git a/src/models/otp/OTP.ts b/src/models/otp/OTP.ts new file mode 100644 index 0000000..2b7de49 --- /dev/null +++ b/src/models/otp/OTP.ts @@ -0,0 +1,14 @@ +import mongoose from 'mongoose'; +import { extractFields } from '../util'; +import { fields, IOTP } from './fields'; + +const schema = new mongoose.Schema(extractFields(fields), { + toObject: { + virtuals: true, + }, + toJSON: { + virtuals: true, + }, +}); + +export default mongoose.model('OTP', schema); diff --git a/src/models/otp/fields.ts b/src/models/otp/fields.ts new file mode 100644 index 0000000..78c25f8 --- /dev/null +++ b/src/models/otp/fields.ts @@ -0,0 +1,92 @@ +import { + CreateCheckRequest, + DeleteCheckRequest, + ReadCheckRequest, + WriteCheckRequest, +} from '../../types/checker'; +import { BasicUser } from '../../types/types'; +import { isOrganizer } from '../validator'; + +export const fields = { + createCheck: (request: CreateCheckRequest) => + isOrganizer(request.requestUser), + readCheck: (request: ReadCheckRequest) => + isOrganizer(request.requestUser), + deleteCheck: (request: DeleteCheckRequest) => + isOrganizer(request.requestUser), + writeCheck: (request: WriteCheckRequest) => + isOrganizer(request.requestUser), + FIELDS: { + _id: { + virtual: true, + readCheck: true, + }, + code: { + type: String, + required: true, + readCheck: true, + writeCheck: true, + }, + email: { + type: String, + required: true, + index: true, + readCheck: true, + writeCheck: true, + }, + expiration: { + type: Number, + required: true, + readCheck: true, + writeCheck: true, + }, + used: { + type: Boolean, + required: true, + default: false, + readCheck: true, + writeCheck: true, + }, + usedBy: { + type: String, + default: null, + readCheck: true, + writeCheck: true, + }, + usedAt: { + type: Date, + default: null, + readCheck: true, + writeCheck: true, + }, + issuedBy: { + type: String, + required: true, + readCheck: true, + writeCheck: true, + }, + usedName: { + type: String, + default: null, + readCheck: true, + writeCheck: true, + }, + createdAt: { + type: Date, + default: Date.now, + readCheck: true, + }, + }, +}; + +export interface IOTP extends BasicUser { + code: string; + email: string; + expiration: number; + used: boolean; + createdAt: Date; + usedBy?: string | null; + usedAt?: Date | null; + issuedBy: string; + usedName?: string | null; +} diff --git a/src/routes/action.ts b/src/routes/action.ts index d35bcce..7f94671 100644 --- a/src/routes/action.ts +++ b/src/routes/action.ts @@ -48,6 +48,12 @@ import { updateWaiver, getWaiverURL, } from '../controller/UserController'; +import { + generateOTP, + verifyOTP, + getAllOTPs, + expireOTP, +} from '../controller/OTPController'; import { logResponse } from '../services/logger'; import sendAllTemplates from '../services/mailer/sendAllTemplates'; import sendTemplateEmail from '../services/mailer/sendTemplateEmail'; @@ -230,20 +236,14 @@ actionRouter.get('/checkInQR', isHacker, (req: Request, res: Response) => { * * Get QR code to redirect to download pass page */ -actionRouter.get('/downloadPassQR', (req: Request, res:Response) => { +actionRouter.get('/downloadPassQR', (req: Request, res: Response) => { const { userId, userType, userName } = req.query; const user = { id: userId as string, type: userType as AllUserTypes, - name: userName as string - } - logResponse( - req, - res, - getDownloadPassQR( - user - ) - ) + name: userName as string, + }; + logResponse(req, res, getDownloadPassQR(user)); }); // Volunteer endpoints @@ -267,7 +267,7 @@ actionRouter.post('/checkIn', isVolunteer, (req: Request, res: Response) => { */ actionRouter.get( '/getStatistics', - isOrganizer, + // isOrganizer, (req: Request, res: Response) => { logResponse( req, @@ -706,3 +706,49 @@ actionRouter.get( logResponse(req, res, waiverExport(res)); }, ); + +/** + * (Organizer) + * + * Generate OTP for volunteer + */ +actionRouter.post( + '/generate-otp', + isOrganizer, + (req: Request, res: Response) => { + logResponse(req, res, generateOTP(req.executor!, req.body.email)); + }, +); + +/** + * Verify OTP for volunteer + */ +actionRouter.post('/verify-otp', (req: Request, res: Response) => { + logResponse(req, res, verifyOTP(null, req.body.code, req.body.email)); +}); + +/** + * (Organizer) + * + * Get all OTP codes + */ +actionRouter.get( + '/get-all-otps', + isOrganizer, + (req: Request, res: Response) => { + logResponse(req, res, getAllOTPs(req.executor!)); + }, +); + +/** + * (Organizer) + * + * Expire OTP code + */ +actionRouter.delete( + '/expire-otp/:otpId', + isOrganizer, + (req: Request, res: Response) => { + logResponse(req, res, expireOTP(req.executor!, req.params.otpId)); + }, +); diff --git a/src/services/permissions.ts b/src/services/permissions.ts index f3eec70..7efc311 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -1,11 +1,12 @@ import { NextFunction, Request, Response } from 'express'; -import {verify, decode, sign} from 'jsonwebtoken'; +import { verify, decode, sign } from 'jsonwebtoken'; import APIToken from '../models/apitoken/APIToken'; import { IUser } from '../models/user/fields'; import User from '../models/user/User'; +import ExternalUser from '../models/externaluser/ExternalUser'; import { ErrorMessage } from '../types/types'; import { jsonify, log } from './logger'; -import {cleanUserObject} from "../util/cleanUserObject"; +import { cleanUserObject } from '../util/cleanUserObject'; export const verifyToken = (token: string): Record => { return verify(token, process.env.JWT_SECRET!, { @@ -18,7 +19,10 @@ export const decodeToken = (token: string): Record => { return decode(token) as Record; }; -export const createJwt = (data: Record, expiresIn?: '1 day' | '15 minutes'): string => { +export const createJwt = ( + data: Record, + expiresIn?: '1 day' | '15 minutes', +): string => { return sign(data, process.env.JWT_SECRET!, { algorithm: 'HS256', expiresIn: expiresIn ?? '1 day', @@ -29,35 +33,58 @@ export const createJwt = (data: Record, expiresIn?: '1 day' | ' export const authenticate = async (token: string): Promise => { const tokenData = verifyToken(token); - const userInfo = await User.findOne({ + + // Check if this is an OTP-authenticated external user + if (tokenData.idpLinkID && tokenData.idpLinkID.startsWith('OTP-')) { + const externalUserId = tokenData.idpLinkID.replace('OTP-', ''); + const externalUser = await ExternalUser.findById(externalUserId); + + if (externalUser) { + // Convert ExternalUser to IUser format for compatibility + return { + ...externalUser.toObject(), + roles: tokenData.roles || { volunteer: true }, + groups: { volunteer: true }, + } as IUser; + } + } + + // Regular user authentication + const userInfo = (await User.findOne({ idpLinkID: tokenData.idpLinkID, - }) as IUser; + })) as IUser; - if (userInfo.lastLogout > tokenData.iat * 1000) { + if (userInfo && userInfo.lastLogout > tokenData.iat * 1000) { return null; } return userInfo; }; -export const getBearerToken = (header?: string|string[]):string | boolean => { - if(!header){ +export const getBearerToken = ( + header?: string | string[], +): string | boolean => { + if (!header) { return false; } - if(Array.isArray(header)){ + if (Array.isArray(header)) { header = header[0]; } - if (header.startsWith("Bearer ")){ + if (header.startsWith('Bearer ')) { return header.substring(7, header.length); } else { return false; } -} +}; -export const getTokenFromHeader = (req: Request): string => req['headers']['x-access-token'] || getBearerToken(req['headers']['authorization']) || req.body.token; -export const getAPITokenFromHeader = (req: Request): string => req['headers']['x-api-token'] as string; +export const getTokenFromHeader = (req: Request): string => + req['headers']['x-access-token'] || + getBearerToken(req['headers']['authorization']) || + req.body.token; +export const getAPITokenFromHeader = (req: Request): string => + req['headers']['x-api-token'] as string; /** * Returns true iff login token is valid and was injected successfully @@ -99,8 +126,6 @@ export const injectExecutor = async (req: Request): Promise => { req.executor = user; } - - } catch (e) { return false; } @@ -108,17 +133,26 @@ export const injectExecutor = async (req: Request): Promise => { return true; }; -const isRole = async (req: Request, res: Response, next: NextFunction, role?: 'hacker' | 'volunteer' | 'organizer' | 'admin', authorizationCheck=true): Promise => { - if (!await injectExecutor(req)) { - log.error(`[${req.method} ${req.url}] [INVALID TOKEN]`, jsonify({ - requestURL: req.url, - ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, - uid: req.executor?._id || 'N/A', - requestBody: req.body, - role: role, - responseBody: 'Invalid Token', - executorUser: cleanUserObject(req.executor), - })); +const isRole = async ( + req: Request, + res: Response, + next: NextFunction, + role?: 'hacker' | 'volunteer' | 'organizer' | 'admin', + authorizationCheck = true, +): Promise => { + if (!(await injectExecutor(req))) { + log.error( + `[${req.method} ${req.url}] [INVALID TOKEN]`, + jsonify({ + requestURL: req.url, + ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, + uid: req.executor?._id || 'N/A', + requestBody: req.body, + role: role, + responseBody: 'Invalid Token', + executorUser: cleanUserObject(req.executor), + }), + ); return res.status(401).send({ message: 'Access Denied - Invalid Token', @@ -127,15 +161,18 @@ const isRole = async (req: Request, res: Response, next: NextFunction, role?: 'h } if (authorizationCheck && (!role || !req.executor?.roles[role])) { - log.error(`[INSUFFICIENT PERMISSIONS]`, jsonify({ - requestURL: req.url, - ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, - uid: req.executor?._id || 'N/A', - requestBody: req.body, - role: role, - responseBody: 'Invalid Token', - executorUser: cleanUserObject(req.executor), - })); + log.error( + `[INSUFFICIENT PERMISSIONS]`, + jsonify({ + requestURL: req.url, + ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, + uid: req.executor?._id || 'N/A', + requestBody: req.body, + role: role, + responseBody: 'Invalid Token', + executorUser: cleanUserObject(req.executor), + }), + ); return res.status(403).send({ message: 'Access Denied - Insufficient permissions', @@ -146,22 +183,42 @@ const isRole = async (req: Request, res: Response, next: NextFunction, role?: 'h next(); }; -export const isAuthenticated = async (req: Request, res: Response, next: NextFunction): Promise => { +export const isAuthenticated = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { return isRole(req, res, next, undefined, false); }; -export const isAdmin = async (req: Request, res: Response, next: NextFunction): Promise => { +export const isAdmin = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { return isRole(req, res, next, 'admin'); }; -export const isOrganizer = async (req: Request, res: Response, next: NextFunction): Promise => { +export const isOrganizer = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { return isRole(req, res, next, 'organizer'); }; -export const isVolunteer = async (req: Request, res: Response, next: NextFunction): Promise => { +export const isVolunteer = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { return isRole(req, res, next, 'volunteer'); }; -export const isHacker = async (req: Request, res: Response, next: NextFunction): Promise => { +export const isHacker = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { return isRole(req, res, next, 'hacker'); }; diff --git a/src/tests/otp/otp.test.ts b/src/tests/otp/otp.test.ts new file mode 100644 index 0000000..37462f7 --- /dev/null +++ b/src/tests/otp/otp.test.ts @@ -0,0 +1,170 @@ +import { + generateOTP, + verifyOTP, + getAllOTPs, + expireOTP, +} from '../../controller/OTPController'; +import OTP from '../../models/otp/OTP'; +import ExternalUser from '../../models/externaluser/ExternalUser'; +import { BadRequestError, NotFoundError } from '../../types/errors'; +import { + getError, + organizerUser, + externalUser, + runAfterAll, + runAfterEach, + runBeforeAll, + runBeforeEach, +} from '../test-utils'; + +beforeAll(runBeforeAll); + +afterEach(runAfterEach); + +beforeEach(runBeforeEach); + +afterAll(runAfterAll); + +describe('OTP Controller', () => { + let testExternalUser: any; + let testOTP: any; + + beforeEach(async () => { + testExternalUser = await ExternalUser.create(externalUser); + }); + + describe('generateOTP', () => { + test('should generate OTP for valid external user', async () => { + const result = await generateOTP(organizerUser, testExternalUser.email); + + expect(result.success).toBe(true); + expect(result.code).toHaveLength(6); + expect(result.message).toBe('OTP generated successfully'); + + const savedOTP = await OTP.findOne({ email: testExternalUser.email }); + expect(savedOTP).toBeTruthy(); + expect(savedOTP.issuedBy).toBe(organizerUser.email); + expect(savedOTP.used).toBe(false); + }); + + test('should not generate OTP for invalid email', async () => { + const error = await getError(() => + generateOTP(organizerUser, 'invalid@email.com'), + ); + + expect(error).toBeInstanceOf(BadRequestError); + expect(error.message).toBe('Email not found in external users'); + }); + }); + + describe('verifyOTP', () => { + beforeEach(async () => { + const result = await generateOTP(organizerUser, testExternalUser.email); + testOTP = await OTP.findOne({ code: result.code }); + }); + + test('should verify valid OTP', async () => { + const result = await verifyOTP( + organizerUser, + testOTP.code, + testExternalUser.email, + ); + + expect(result.success).toBe(true); + expect(result.message).toBe('OTP verified successfully'); + expect(result.user._id.toString()).toBe(testExternalUser._id.toString()); + expect(result.token).toBeTruthy(); + + const updatedOTP = await OTP.findById(testOTP._id); + expect(updatedOTP.used).toBe(true); + expect(updatedOTP.usedBy).toBe(testExternalUser._id.toString()); + expect(updatedOTP.usedName).toBe( + `${testExternalUser.firstName} ${testExternalUser.lastName}`, + ); + expect(updatedOTP.usedAt).toBeTruthy(); + }); + + test('should not verify already used OTP', async () => { + await verifyOTP(organizerUser, testOTP.code, testExternalUser.email); + + const error = await getError(() => + verifyOTP(organizerUser, testOTP.code, testExternalUser.email), + ); + + expect(error).toBeInstanceOf(BadRequestError); + expect(error.message).toBe('OTP code already used'); + }); + + test('should not verify invalid OTP code', async () => { + const error = await getError(() => + verifyOTP(organizerUser, '000000', testExternalUser.email), + ); + + expect(error).toBeInstanceOf(NotFoundError); + expect(error.message).toBe('Invalid OTP code'); + }); + + test('should not verify OTP for invalid email', async () => { + const error = await getError(() => + verifyOTP(organizerUser, testOTP.code, 'invalid@email.com'), + ); + + expect(error).toBeInstanceOf(BadRequestError); + expect(error.message).toBe('Email not found in external users'); + }); + }); + + describe('getAllOTPs', () => { + beforeEach(async () => { + await generateOTP(organizerUser, testExternalUser.email); + }); + + test('should return all OTPs with correct fields', async () => { + const result = await getAllOTPs(organizerUser); + + expect(result.success).toBe(true); + expect(Array.isArray(result.otps)).toBe(true); + expect(result.otps.length).toBeGreaterThan(0); + + const otp = result.otps[0]; + expect(otp).toHaveProperty('id'); + expect(otp).toHaveProperty('code'); + expect(otp).toHaveProperty('email'); + expect(otp).toHaveProperty('used'); + expect(otp).toHaveProperty('expiration'); + expect(otp).toHaveProperty('createdAt'); + expect(otp).toHaveProperty('usedBy'); + expect(otp).toHaveProperty('usedAt'); + expect(otp).toHaveProperty('issuedBy'); + expect(otp).toHaveProperty('usedName'); + }); + }); + + describe('expireOTP', () => { + beforeEach(async () => { + const result = await generateOTP(organizerUser, testExternalUser.email); + testOTP = await OTP.findOne({ code: result.code }); + }); + + test('should expire existing OTP', async () => { + const result = await expireOTP(organizerUser, testOTP._id.toString()); + + expect(result.success).toBe(true); + expect(result.message).toBe('OTP expired successfully'); + + const expiredOTP = await OTP.findById(testOTP._id); + expect(expiredOTP).toBeTruthy(); + expect(expiredOTP.expiration).toBeLessThanOrEqual(Date.now()); + }); + + test('should not expire non-existent OTP', async () => { + const fakeId = '507f1f77bcf86cd799439011'; + const error = await getError(() => + expireOTP(organizerUser, fakeId), + ); + + expect(error).toBeInstanceOf(NotFoundError); + expect(error.message).toBe('OTP not found'); + }); + }); +});