Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("otps", "attempts_by_ip", {
allowNull: false,
type: Sequelize.JSONB,
defaultValue: {},
})
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("otps", "attempts_by_ip")
},
}
8 changes: 8 additions & 0 deletions src/database/models/Otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ export class Otp extends Model {

@UpdatedAt
updatedAt!: Date

// JSON map of ip -> attempts for per-IP tracking
@Column({
allowNull: false,
type: DataType.JSONB,
defaultValue: {},
})
attemptsByIp!: Record<string, number>
}
5 changes: 4 additions & 1 deletion src/routes/v2/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ class AuthRouter {
message: `Invalid request format: ${error.message}`,
})
const email = rawEmail.toLowerCase()
const userInfo = (await this.authService.verifyOtp({ email, otp })).value
const userIp = isSecure ? req.get("cf-connecting-ip") : req.ip
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this line is making an assumption about Cloudflare's existence which we know may not necessarily be true. Noting this down as a nit for us to follow up as a separate fix as part of the Cloudflare post-mortem action items.

FYI will extract this out into a common function and add a comment there for follow-up.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted in 9f90a78.

const userInfo = (
await this.authService.verifyOtp({ email, otp, clientIp: userIp })
).value
Object.assign(req.session, { userInfo })
logger.info(`User ${userInfo.email} successfully logged in`)
return res.sendStatus(200)
Expand Down
7 changes: 5 additions & 2 deletions src/routes/v2/authenticated/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import UserSessionData from "@classes/UserSessionData"
import DatabaseError from "@root/errors/DatabaseError"
import { isError, RequestHandler } from "@root/types"
import { nameAnonymousMethods } from "@root/utils/apm-utils"
import { isSecure } from "@root/utils/auth-utils"
import {
VerifyEmailOtpSchema,
VerifyMobileNumberOtpSchema,
Expand Down Expand Up @@ -83,8 +84,9 @@ export class UsersRouter {
const userId = userSessionData.isomerUserId
const parsedEmail = email.toLowerCase()

const userIp = isSecure ? req.get("cf-connecting-ip") : req.ip
return this.usersService
.verifyEmailOtp(parsedEmail, otp)
.verifyEmailOtp(parsedEmail, otp, userIp)
.andThen(() =>
ResultAsync.fromPromise(
this.usersService.updateUserByIsomerId(userId, {
Expand Down Expand Up @@ -142,8 +144,9 @@ export class UsersRouter {
const { userSessionData } = res.locals
const userId = userSessionData.isomerUserId

const userIp = isSecure ? req.get("cf-connecting-ip") : req.ip
return this.usersService
.verifyMobileOtp(mobile, otp)
.verifyMobileOtp(mobile, otp, userIp)
.andThen(() =>
ResultAsync.fromPromise(
this.usersService.updateUserByIsomerId(userId, {
Expand Down
52 changes: 49 additions & 3 deletions src/services/identity/UsersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,40 @@ class UsersService {

async sendEmailOtp(email: string) {
const normalizedEmail = email.toLowerCase()

// Check if there's already a valid OTP for this email
// This prevents creating new OTPs while a valid one exists, mitigating
// the attack vector where an attacker spam OTP requests
// to prevent the user from logging in
const existingOtp = await this.otpRepository.findOne({
where: {
email: normalizedEmail,
hashedOtp: {
[Op.regexp]: "\\S+", // at least one non-whitespace character (i.e. is truthy!)
},
},
})
if (existingOtp && existingOtp.expiresAt >= new Date()) {
logger.info({
message: "OTP request blocked: valid OTP already exists",
meta: {
email: normalizedEmail,
expiresAt: existingOtp.expiresAt,
},
})
// Return silently to avoid revealing whether an OTP exists
// This maintains security by not leaking information about existing OTPs
return
Comment on lines +236 to +238
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I don't think we are able to directly return here because it is possible that the user is requesting for the OTP to be resent if the email did not reach their inbox. Instead we should be able to resend the email with the same OTP.

}

const { otp, hashedOtp } = await this.otpService.generateLoginOtpWithHash()

// Reset attempts to login
await this.otpRepository.upsert({
email: normalizedEmail,
hashedOtp,
attempts: 0,
attemptsByIp: {},
expiresAt: this.getOtpExpiry(),
})

Expand All @@ -239,6 +266,7 @@ class UsersService {
mobileNumber,
hashedOtp,
attempts: 0,
attemptsByIp: {},
expiresAt: this.getOtpExpiry(),
})

Expand Down Expand Up @@ -271,10 +299,12 @@ class UsersService {
otp,
findConditions,
findErrorMessage,
clientIp = "unknown", // default to 'unknown' bucket when IP missing to ensure users are not locked out
}: {
otp: string | undefined
findConditions: { email: string } | { mobileNumber: string }
findErrorMessage: string
clientIp?: string
}) {
if (!otp) {
return errAsync(new BadRequestError("Empty OTP provided"))
Expand Down Expand Up @@ -316,17 +346,27 @@ class UsersService {
)
}

if (otpEntry.attempts >= MAX_NUM_OTP_ATTEMPTS) {
// GTA-80-009 WP2: Enforce per-IP attempts;
const attemptsByIp = otpEntry.attemptsByIp || {}
const attemptsForIp = attemptsByIp[clientIp] ?? 0
if (attemptsForIp >= MAX_NUM_OTP_ATTEMPTS) {
// should this delete the otpEntry as well?
return errAsync(new BadRequestError("Max number of attempts reached"))
}

// We must successfully be able to increment the otp record attempts before any processing, to prevent brute-force race condition
// Consult GTA-24-012 WP3 for details
// atomically increment attempts for this IP using JSONB concatenation guarded by current value
const newAttemptsForIp = attemptsForIp + 1
const attemptsByIpUpdated = { ...(otpEntry.attemptsByIp || {}) }
attemptsByIpUpdated[clientIp] = newAttemptsForIp

return ResultAsync.fromPromise(
this.otpRepository.update(
{
// maintain legacy aggregate attempts for monitoring, but do not enforce with it
attempts: otpEntry.attempts + 1,
attemptsByIp: attemptsByIpUpdated,
},
{
where: {
Expand Down Expand Up @@ -377,21 +417,27 @@ class UsersService {
/* eslint-enable @typescript-eslint/no-non-null-assertion */
}

verifyEmailOtp(email: string, otp: string | undefined) {
verifyEmailOtp(email: string, otp: string | undefined, clientIp?: string) {
const normalizedEmail = email.toLowerCase()

return this.verifyOtp({
otp,
findConditions: { email: normalizedEmail },
findErrorMessage: "Error finding OTP entry when verifying email OTP",
clientIp,
})
}

verifyMobileOtp(mobileNumber: string, otp: string | undefined) {
verifyMobileOtp(
mobileNumber: string,
otp: string | undefined,
clientIp?: string
) {
return this.verifyOtp({
otp,
findConditions: { mobileNumber },
findErrorMessage: "Error finding OTP entry when verifying mobile OTP",
clientIp,
})
}

Expand Down
82 changes: 80 additions & 2 deletions src/services/identity/__tests__/UsersService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ModelStatic } from "sequelize/types"
import { Sequelize } from "sequelize-typescript"

import { config } from "@root/config/config"
import { Otp, User, Whitelist } from "@root/database/models"
import { BadRequestError } from "@root/errors/BadRequestError"
import SmsClient from "@services/identity/SmsClient"
import TotpGenerator from "@services/identity/TotpGenerator"
import MailClient from "@services/utilServices/MailClient"
Expand Down Expand Up @@ -39,8 +41,30 @@ const MockWhitelist = {
findAll: jest.fn(),
}

let dbAttempts = 0
let dbAttemptsByIp: Record<string, number> = {}

const futureDate = new Date(Date.now() + 60 * 60 * 1000)

const MockOtp = {
findOne: jest.fn(),
findOne: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 1,
email: mockEmail,
hashedOtp: "hashed",
attempts: dbAttempts,
attemptsByIp: { ...dbAttemptsByIp },
expiresAt: futureDate,
destroy: jest.fn(),
})
),
update: jest.fn().mockImplementation((values: any, options: any) => {
if (options?.where?.attempts !== dbAttempts) return Promise.resolve([0])

dbAttempts = values.attempts
dbAttemptsByIp = { ...(values.attemptsByIp || {}) }
return Promise.resolve([1])
}),
}

const UsersService = new _UsersService({
Expand All @@ -57,7 +81,11 @@ const mockEmail = "[email protected]"
const mockGithubId = "sudowoodo"

describe("User Service", () => {
afterEach(() => jest.clearAllMocks())
afterEach(() => {
jest.clearAllMocks()
dbAttempts = 0
dbAttemptsByIp = {}
})

it("should return the result of calling the findOne method by email on the db model", () => {
// Arrange
Expand All @@ -74,6 +102,56 @@ describe("User Service", () => {
})
})

describe("verifyOtp per-IP behavior", () => {
const maxAttempts = config.get("auth.maxNumOtpAttempts")
const wrongOtp = "000000"

it("increments attempts for provided IP and returns invalid until max", async () => {
(MockOtpService.verifyOtp as jest.Mock).mockResolvedValue(false)
const ip = "1.1.1.1"

for (let i = 1; i <= maxAttempts; i += 1) {
// eslint-disable-next-line no-await-in-loop
const result = await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ip)
expect(result.isErr()).toBe(true)
const err = result._unsafeUnwrapErr()
expect(err).toBeInstanceOf(BadRequestError)
if (i < maxAttempts) {
expect((err as BadRequestError).message).toBe("OTP is not valid")
} else {
expect((err as BadRequestError).message).toBe(
"Max number of attempts reached"
)
}
}

expect(MockOtp.update).toHaveBeenCalled()
expect(dbAttemptsByIp[ip]).toBe(maxAttempts - 1) // last call rejected before increment
})

it("uses 'unknown' bucket when clientIp is missing", async () => {
(MockOtpService.verifyOtp as jest.Mock).mockResolvedValue(false)

const result = await UsersService.verifyEmailOtp(mockEmail, wrongOtp)
expect(result.isErr()).toBe(true)
result._unsafeUnwrapErr() // ensure unwrap doesn't throw
expect(dbAttemptsByIp.unknown).toBe(1)
})

it("tracks per-IP separately", async () => {
(MockOtpService.verifyOtp as jest.Mock).mockResolvedValue(false)
const ipA = "1.1.1.1"
const ipB = "2.2.2.2"

await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ipA)
await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ipA)
await UsersService.verifyEmailOtp(mockEmail, wrongOtp, ipB)

expect(dbAttemptsByIp[ipA]).toBe(2)
expect(dbAttemptsByIp[ipB]).toBe(1)
})
})

it("should return the result of calling the findOne method by githubId on the db model", () => {
// Arrange
const expected = "user1"
Expand Down
4 changes: 2 additions & 2 deletions src/services/utilServices/AuthService.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ class AuthService {
}
}

async verifyOtp({ email, otp }) {
async verifyOtp({ email, otp, clientIp }) {
return this.usersService
.verifyEmailOtp(email, otp)
.verifyEmailOtp(email, otp, clientIp)
.andThen(() =>
ResultAsync.fromPromise(
this.usersService.loginWithEmail(email),
Expand Down
Loading