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
26 changes: 23 additions & 3 deletions src/services/identity/UsersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ class UsersService {
email: normalizedEmail,
hashedOtp,
attempts: 0,
attemptsByIp: {},
expiresAt: this.getOtpExpiry(),
})

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

Expand Down Expand Up @@ -271,10 +273,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 +320,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 +391,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