Skip to content

Commit

Permalink
chore: migrate FailedAuthRequest to pg
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanh committed Mar 3, 2024
1 parent ba7d724 commit 2608302
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 28 deletions.
2 changes: 1 addition & 1 deletion packages/server/__tests__/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ async function setup() {
// so the safety checks will eventually fail if run too much

await Promise.all([
r.table('FailedAuthRequest').delete().run(),
pg.deleteFrom('FailedAuthRequest').execute(),
r.table('PasswordResetRequest').delete().run(),
pg.deleteFrom('SAMLDomain').where('domain', '=', 'example.com').execute()
])
Expand Down
7 changes: 1 addition & 6 deletions packages/server/database/types/FailedAuthRequest.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import generateUID from '../../generateUID'

interface Input {
id?: string
ip: string
email: string
time?: Date
}

export default class FailedAuthRequest {
id: string
ip: string
email: string
time: Date
constructor(input: Input) {
const {id, email, ip, time} = input
this.id = id ?? generateUID()
const {email, ip, time} = input
this.email = email
this.ip = ip
this.time = time ?? new Date()
Expand Down
52 changes: 32 additions & 20 deletions packages/server/graphql/mutations/helpers/attemptLogin.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,56 @@
import bcrypt from 'bcryptjs'
import {sql} from 'kysely'
import ms from 'ms'
import {AuthenticationError, Threshold} from 'parabol-client/types/constEnums'
import sleep from 'parabol-client/utils/sleep'
import {AuthIdentityTypeEnum} from '../../../../client/types/constEnums'
import getRethink from '../../../database/rethinkDriver'
import {RDatum} from '../../../database/stricterR'
import getKysely from '../../../postgres/getKysely'
import AuthIdentityLocal from '../../../database/types/AuthIdentityLocal'
import AuthToken from '../../../database/types/AuthToken'
import FailedAuthRequest from '../../../database/types/FailedAuthRequest'
import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails'

const logFailedLogin = async (ip: string, email: string) => {
const r = await getRethink()
const pg = getKysely()
if (ip) {
const failedAuthRequest = new FailedAuthRequest({ip, email})
await r.table('FailedAuthRequest').insert(failedAuthRequest).run()
await pg.insertInto('FailedAuthRequest').values(failedAuthRequest).execute()
}
}

const attemptLogin = async (denormEmail: string, password: string, ip = '') => {
const r = await getRethink()
const pg = getKysely()
const yesterday = new Date(Date.now() - ms('1d'))
const email = denormEmail.toLowerCase().trim()

const existingUser = await getUserByEmail(email)
const {failOnAccount, failOnTime} = await r({
failOnAccount: r
.table('FailedAuthRequest')
.getAll(ip, {index: 'ip'})
.filter({email})
.filter((row: RDatum) => row('time').ge(yesterday))
.count()
.ge(Threshold.MAX_ACCOUNT_PASSWORD_ATTEMPTS) as unknown as boolean,
failOnTime: r
.table('FailedAuthRequest')
.getAll(ip, {index: 'ip'})
.filter((row: RDatum) => row('time').ge(yesterday))
.count()
.ge(Threshold.MAX_DAILY_PASSWORD_ATTEMPTS) as unknown as boolean
}).run()
const {failOnAccount, failOnTime} = (await pg
.with('byAccount', (qb) =>
qb
.selectFrom('FailedAuthRequest')
.select((eb) => eb.fn.count<number>('id').as('attempts'))
.where('ip', '=', ip)
.where('email', '=', email)
.where('time', '>=', yesterday)
)
.with('byTime', (qb) =>
qb
.selectFrom('FailedAuthRequest')
.select((eb) => eb.fn.count<number>('id').as('attempts'))
.where('ip', '=', ip)
.where('time', '>=', yesterday)
)
.selectFrom(['byAccount', 'byTime'])
.select(({ref}) => [
sql<boolean>`${ref('byAccount.attempts')} >= ${Threshold.MAX_ACCOUNT_PASSWORD_ATTEMPTS}`.as(
'failOnAccount'
),
sql<boolean>`${ref('byTime.attempts')} >= ${Threshold.MAX_DAILY_PASSWORD_ATTEMPTS}`.as(
'failOnTime'
)
])
.executeTakeFirst()) as {failOnAccount: boolean; failOnTime: boolean}

if (failOnAccount || failOnTime) {
await sleep(1000)
// silently fail to trick security researchers
Expand Down
4 changes: 3 additions & 1 deletion packages/server/graphql/mutations/resetPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import bcrypt from 'bcryptjs'
import {GraphQLID, GraphQLNonNull, GraphQLString} from 'graphql'
import {Security, Threshold} from 'parabol-client/types/constEnums'
import {AuthIdentityTypeEnum} from '../../../client/types/constEnums'
import getKysely from '../../postgres/getKysely'
import getRethink from '../../database/rethinkDriver'
import AuthIdentityLocal from '../../database/types/AuthIdentityLocal'
import AuthToken from '../../database/types/AuthToken'
Expand Down Expand Up @@ -37,6 +38,7 @@ const resetPassword = {
if (process.env.AUTH_INTERNAL_DISABLED === 'true') {
return {error: {message: 'Resetting password is disabled'}}
}
const pg = getKysely()
const r = await getRethink()
const resetRequest = (await r
.table('PasswordResetRequest')
Expand Down Expand Up @@ -73,7 +75,7 @@ const resetPassword = {
localIdentity.isEmailVerified = true
await Promise.all([
updateUser({identities}, userId),
r.table('FailedAuthRequest').getAll(email, {index: 'email'}).delete().run()
pg.deleteFrom('FailedAuthRequest').where('email', '=', email).execute()
])
context.authToken = new AuthToken({sub: userId, tms, rol})
await blacklistJWT(userId, context.authToken.iat, context.socketId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {Client} from 'pg'
import getPgConfig from '../getPgConfig'

export async function up() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
CREATE TABLE "FailedAuthRequest" (
"id" SERIAL PRIMARY KEY,
"email" "citext" NOT NULL,
"ip" "inet" NOT NULL,
"time" TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_FailedAuthRequest_email" ON "FailedAuthRequest"("email");
CREATE INDEX IF NOT EXISTS "idx_FailedAuthRequest_ip" ON "FailedAuthRequest"("ip");
`)
await client.end()
}

export async function down() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
DROP TABLE IF EXISTS "FailedAuthRequest";
`)
await client.end()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {FirstParam} from 'parabol-client/types/generics'
import {Client} from 'pg'
import {r} from 'rethinkdb-ts'
import getPgConfig from '../getPgConfig'
import connectRethinkDB from '../../database/connectRethinkDB'
import getPgp from '../getPgp'

export async function up() {
await connectRethinkDB()
const {pgp, pg} = getPgp()
const batchSize = 1000

try {
await r.table('FailedAuthRequest').indexCreate('time').run()
await r.table('FailedAuthRequest').indexWait().run()
} catch {}

const columnSet = new pgp.helpers.ColumnSet(['email', 'ip', 'time'], {table: 'FailedAuthRequest'})

const getNextData = async (leftBoundCursor: Date | undefined) => {
const startAt = leftBoundCursor || r.minval
const nextBatch = await r
.table('FailedAuthRequest')
.between(startAt, r.maxval, {index: 'time', leftBound: 'open'})
.orderBy({index: 'time'})
.limit(batchSize)
.run()
if (nextBatch.length === 0) return null
if (nextBatch.length < batchSize) return nextBatch
const lastItem = nextBatch.pop()
const lastMatchingTime = nextBatch.findLastIndex((item) => item.time !== lastItem!.time)
if (lastMatchingTime === -1) {
throw new Error(
'batchSize is smaller than the number of items that share the same cursor. Increase batchSize'
)
}
return nextBatch.slice(0, lastMatchingTime)
}

await pg.tx('FailedAuthRequest', (task) => {
const fetchAndProcess: FirstParam<typeof task.sequence> = async (
_index,
leftBoundCursor: undefined | Date
) => {
const nextData = await getNextData(leftBoundCursor)
if (!nextData) return undefined
const insert = pgp.helpers.insert(nextData, columnSet)
await task.none(insert)
return nextData.at(-1)!.runAt
}
return task.sequence(fetchAndProcess)
})
await r.getPoolMaster()?.drain()
}

export async function down() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`DELETE FROM "FailedAuthRequest"`)
await client.end()
try {
await r.table('FailedAuthRequest').indexDrop('time').run()
} catch {}
}

0 comments on commit 2608302

Please sign in to comment.