Skip to content

Commit

Permalink
feat: enforce max 10 OTP requests for verified field (#3952)
Browse files Browse the repository at this point in the history
* feat: enforce max 10 OTP requests for verified field

* test: update tests

* chore: update error message
  • Loading branch information
mantariksh authored Jun 6, 2022
1 parent b4405ea commit 346e58c
Show file tree
Hide file tree
Showing 9 changed files with 89 additions and 1 deletion.
2 changes: 2 additions & 0 deletions shared/utils/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const SALT_ROUNDS = 10
export const TRANSACTION_EXPIRE_AFTER_SECONDS = 14400 // 4 hours
export const HASH_EXPIRE_AFTER_SECONDS = 60 * 30 // 30 minutes
export const WAIT_FOR_OTP_SECONDS = 30
export const MAX_OTP_REQUESTS = 10

/**
* WAIT_FOR_OTP_SECONDS tolerance. Server allows OTPs to be requested every
* (WAIT_FOR_OTP_SECONDS - WAIT_FOR_OTP_TOLERANCE_SECONDS) seconds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('Verification Model', () => {
hashedOtp: 'hashedOtp',
hashCreatedAt: new Date(),
hashRetries: 5,
otpRequests: 6,
}
const vfnParams = merge({}, VFN_PARAMS, { fields: [field] })
const verification = new VerificationModel(vfnParams)
Expand Down Expand Up @@ -457,6 +458,7 @@ describe('Verification Model', () => {
hashedOtp: 'mockHashedOtp',
hashCreatedAt: new Date(),
hashRetries: 3,
otpRequests: 3,
}
const transaction = await VerificationModel.create({
...VFN_PARAMS,
Expand All @@ -477,6 +479,7 @@ describe('Verification Model', () => {
expect(result!.fields[0].hashCreatedAt).not.toEqual(field.hashCreatedAt)
expect(result!.fields[0].signedData).toEqual(updateParams.signedData)
expect(result!.fields[0].hashedOtp).toEqual(updateParams.hashedOtp)
expect(result!.fields[0].otpRequests).toEqual(field.otpRequests + 1)

expect(result!.formId).toEqual(transaction.formId)
})
Expand All @@ -488,13 +491,15 @@ describe('Verification Model', () => {
hashedOtp: 'mockHashedOtp',
hashCreatedAt: new Date(),
hashRetries: 3,
otpRequests: 1,
}
const field2 = {
...generateFieldParams(),
signedData: 'mockSignedData2',
hashedOtp: 'mockHashedOtp2',
hashCreatedAt: new Date(),
hashRetries: 2,
otpRequests: 3,
}
const transaction = await VerificationModel.create({
...VFN_PARAMS,
Expand All @@ -517,12 +522,14 @@ describe('Verification Model', () => {
)
expect(result!.fields[0].signedData).toEqual(updateParams.signedData)
expect(result!.fields[0].hashedOtp).toEqual(updateParams.hashedOtp)
expect(result!.fields[0].otpRequests).toEqual(field1.otpRequests + 1)

// field2 should remain completely unchanged
expect(result!.fields[1].hashRetries).toEqual(field2.hashRetries)
expect(result!.fields[1].signedData).toEqual(field2.signedData)
expect(result!.fields[1].hashedOtp).toEqual(field2.hashedOtp)
expect(result!.fields[1].hashCreatedAt).toEqual(field2.hashCreatedAt)
expect(result!.fields[1].otpRequests).toEqual(field2.otpRequests)

expect(result!.formId).toEqual(transaction.formId)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ MockLoggerModule.createLoggerWithLabel.mockReturnValue(mockLogger)
import { smsConfig } from 'src/app/config/features/sms.config'
import formsgSdk from 'src/app/config/formsg-sdk'
import * as FormService from 'src/app/modules/form/form.service'
import { OtpRequestError } from 'src/app/modules/verification/verification.errors'
import {
OtpRequestCountExceededError,
OtpRequestError,
} from 'src/app/modules/verification/verification.errors'
import {
MailGenerationError,
MailSendError,
Expand Down Expand Up @@ -442,6 +445,36 @@ describe('Verification service', () => {
expect(result._unsafeUnwrapErr()).toEqual(new WaitForOtpError())
})

it('should return OtpRequestCountExceededError when OTP max requests are exceeded', async () => {
const maxExceededOtpField = generateFieldParams({
otpRequests: 11,
})
const maxExceededOtpTransaction = await VerificationModel.create({
formId: mockFormId,
// Expire 1 hour in future
expireAt: addHours(new Date(), 1),
fields: [maxExceededOtpField],
})

const result = await VerificationService.sendNewOtp({
transactionId: maxExceededOtpTransaction._id,
fieldId: maxExceededOtpField._id,
hashedOtp: MOCK_HASHED_OTP,
otp: MOCK_OTP,
recipient: MOCK_RECIPIENT,
})

expect(MockMailService.sendVerificationOtp).not.toHaveBeenCalled()
expect(MockSmsFactory.sendVerificationOtp).not.toHaveBeenCalled()
expect(
MockFormsgSdk.verification.generateSignature,
).not.toHaveBeenCalled()
expect(updateHashSpy).not.toHaveBeenCalled()
expect(result._unsafeUnwrapErr()).toEqual(
new OtpRequestCountExceededError(),
)
})

it('should forward errors returned by MailService.sendVerificationOtp', async () => {
const error = new MailSendError()
MockMailService.sendVerificationOtp.mockReturnValueOnce(errAsync(error))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const VFN_FIELD_DEFAULTS = {
hashedOtp: null,
hashCreatedAt: null,
hashRetries: 0,
otpRequests: 0,
}

export const generateFieldParams = (
Expand Down
9 changes: 9 additions & 0 deletions src/app/modules/verification/verification.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ export class WaitForOtpError extends ApplicationError {
}
}

/**
* Max OTP request count exceeded
*/
export class OtpRequestCountExceededError extends ApplicationError {
constructor(message = 'Max OTP request count exceeded') {
super(message)
}
}

/**
* Unsupported field type for OTP verification
*/
Expand Down
6 changes: 6 additions & 0 deletions src/app/modules/verification/verification.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ const VerificationFieldSchema = new Schema<IVerificationFieldSchema>({
// No ttl index is applied on hashCreatedAt, as we do not want to delete the
// entire document when a hash expires.
hashCreatedAt: { type: Date, default: null },
// Number of retries attempted for a given OTP
hashRetries: { type: Number, default: 0 },
// Number of OTPs requested for this field
otpRequests: { type: Number, default: 0 },
})

const compileVerificationModel = (db: Mongoose): IVerificationModel => {
Expand Down Expand Up @@ -165,6 +168,9 @@ const compileVerificationModel = (db: Mongoose): IVerificationModel => {
'fields.$.signedData': updateData.signedData,
'fields.$.hashRetries': 0,
},
$inc: {
'fields.$.otpRequests': 1,
},
},
{
new: true,
Expand Down
12 changes: 12 additions & 0 deletions src/app/modules/verification/verification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
MissingHashDataError,
NonVerifiedFieldTypeError,
OtpExpiredError,
OtpRequestCountExceededError,
OtpRequestError,
OtpRetryExceededError,
TransactionExpiredError,
Expand All @@ -52,6 +53,7 @@ import { SendOtpParams } from './verification.types'
import {
hasAdminExceededFreeSmsLimit,
isOtpExpired,
isOtpRequestCountExceeded,
isOtpWaitTimeElapsed,
isTransactionExpired,
} from './verification.util'
Expand Down Expand Up @@ -278,6 +280,7 @@ export const resetFieldForTransaction = (
* @returns err(TransactionExpiredError) when transaction is expired
* @returns err(FieldNotFoundInTransactionError) when field does not exist
* @returns err(WaitForOtpError) when waiting time for new OTP has not elapsed
* @returns err(OtpRequestCountExceededError) when max OTP requests has been exceeded
* @returns err(MalformedParametersError) when form data to send SMS OTP cannot be retrieved
* @returns err(SmsSendError) when attempt to send OTP SMS fails
* @returns err(InvalidNumberError) when SMS recipient is invalid
Expand All @@ -298,6 +301,7 @@ export const sendNewOtp = ({
| FieldNotFoundInTransactionError
| TransactionExpiredError
| WaitForOtpError
| OtpRequestCountExceededError
| MalformedParametersError
| SmsSendError
| InvalidNumberError
Expand All @@ -322,6 +326,14 @@ export const sendNewOtp = ({
return errAsync(new WaitForOtpError())
}

if (isOtpRequestCountExceeded(field.otpRequests)) {
logger.warn({
message: 'Max OTP request count exceeded',
meta: logMeta,
})
return errAsync(new OtpRequestCountExceededError())
}

return sendOtpForField(transaction.formId, field, recipient, otp)
})
.andThen(() => {
Expand Down
17 changes: 17 additions & 0 deletions src/app/modules/verification/verification.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { StatusCodes } from 'http-status-codes'

import {
HASH_EXPIRE_AFTER_SECONDS,
MAX_OTP_REQUESTS,
VERIFIED_FIELDTYPES,
WAIT_FOR_OTP_SECONDS,
WAIT_FOR_OTP_TOLERANCE_SECONDS,
Expand All @@ -14,6 +15,7 @@ import {
import { smsConfig } from '../../config/features/sms.config'
import { createLoggerWithLabel } from '../../config/logger'
import {
OtpRequestCountExceededError,
OtpRequestError,
SmsLimitExceededError,
} from '../../modules/verification/verification.errors'
Expand Down Expand Up @@ -124,6 +126,16 @@ export const isOtpWaitTimeElapsed = (hashCreatedAt: Date | null): boolean => {
return currentDate > elapseAt
}

/**
* Computes whether the number of OTPs requested has exceeded the maximum allowed.
* @param otpRequests Number of OTPs already requested
* @returns true if the number of OTPs has exceeded the maximum allowed
*/
export const isOtpRequestCountExceeded = (otpRequests: number): boolean => {
// Use >= because otpRequests is the number of OTPs previously requested
return otpRequests >= MAX_OTP_REQUESTS
}

export const mapRouteError: MapRouteError = (
error,
coreErrorMsg = 'Sorry, something went wrong. Please refresh and try again.',
Expand Down Expand Up @@ -155,6 +167,11 @@ export const mapRouteError: MapRouteError = (
errorMessage: `You must wait for ${WAIT_FOR_OTP_SECONDS} seconds between each OTP request.`,
statusCode: StatusCodes.UNPROCESSABLE_ENTITY,
}
case OtpRequestCountExceededError:
return {
errorMessage: `You have requested too many OTPs. Please refresh and try again.`,
statusCode: StatusCodes.BAD_REQUEST,
}
case InvalidNumberError:
return {
errorMessage:
Expand Down
1 change: 1 addition & 0 deletions src/types/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface IVerificationField {
hashedOtp: string | null
hashCreatedAt: Date | null
hashRetries: number
otpRequests: number
}

export interface IVerificationFieldSchema
Expand Down

0 comments on commit 346e58c

Please sign in to comment.