Skip to content

Commit

Permalink
chore(rethinkdb): PasswordResetRequest: One-shot (#10210)
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <matt.krick@gmail.com>
  • Loading branch information
mattkrick authored Sep 11, 2024
1 parent 1131785 commit 12315b0
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 78 deletions.
7 changes: 3 additions & 4 deletions packages/server/__tests__/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {sql} from 'kysely'
import '../../../scripts/webpack/utils/dotenv'
import getRethink from '../database/rethinkDriver'
import getKysely from '../postgres/getKysely'

async function setup() {
const r = await getRethink()
// The IP address is always localhost
// so the safety checks will eventually fail if run too much

await Promise.all([r.table('PasswordResetRequest').delete().run()])
await sql`TRUNCATE TABLE "PasswordResetRequest"`.execute(getKysely())
}

export default setup
5 changes: 0 additions & 5 deletions packages/server/database/rethinkDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import NotificationResponseReplied from './types/NotificationResponseReplied'
import NotificationTaskInvolves from './types/NotificationTaskInvolves'
import NotificationTeamArchived from './types/NotificationTeamArchived'
import NotificationTeamInvitation from './types/NotificationTeamInvitation'
import PasswordResetRequest from './types/PasswordResetRequest'
import PushInvitation from './types/PushInvitation'
import Task from './types/Task'

Expand Down Expand Up @@ -54,10 +53,6 @@ export type RethinkSchema = {
| NotificationMentioned
index: 'userId'
}
PasswordResetRequest: {
type: PasswordResetRequest
index: 'email' | 'ip' | 'token'
}
PushInvitation: {
type: PushInvitation
index: 'userId'
Expand Down
28 changes: 0 additions & 28 deletions packages/server/database/types/PasswordResetRequest.ts

This file was deleted.

41 changes: 22 additions & 19 deletions packages/server/graphql/mutations/emailPasswordReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import ms from 'ms'
import {AuthenticationError, Threshold} from 'parabol-client/types/constEnums'
import {AuthIdentityTypeEnum} from '../../../client/types/constEnums'
import getSSODomainFromEmail from '../../../client/utils/getSSODomainFromEmail'
import getRethink from '../../database/rethinkDriver'
import {RDatum} from '../../database/stricterR'
import AuthIdentityLocal from '../../database/types/AuthIdentityLocal'
import getKysely from '../../postgres/getKysely'
import {getUserByEmail} from '../../postgres/queries/getUsersByEmails'
import {GQLContext} from '../graphql'
import rateLimit from '../rateLimit'
Expand All @@ -31,27 +30,31 @@ const emailPasswordReset = {
return {error: {message: 'Resetting password is disabled'}}
}
const email = denormEmail.toLowerCase().trim()
const r = await getRethink()

// we only wanna send like 2 emails/min or 5 per day to the same person
const yesterday = new Date(Date.now() - ms('1d'))
const user = await getUserByEmail(email)
const {failOnAccount, failOnTime} = await r({
failOnAccount: r
.table('PasswordResetRequest')
.getAll(ip, {index: 'ip'})
.filter({email})
.filter((row: RDatum) => row('time').ge(yesterday))
.count()
.ge(Threshold.MAX_ACCOUNT_DAILY_PASSWORD_RESETS) as unknown as boolean,
failOnTime: r
.table('PasswordResetRequest')
.getAll(ip, {index: 'ip'})
.filter((row: RDatum) => row('time').ge(yesterday))
.count()
.ge(Threshold.MAX_DAILY_PASSWORD_RESETS) as unknown as boolean
}).run()
if (failOnAccount || failOnTime) {
const pg = getKysely()
const [failOnAccount, failOnTime] = await Promise.all([
pg
.selectFrom('PasswordResetRequest')
.where('ip', '=', ip)
.where('email', '=', email)
.where('time', '>=', yesterday)
.select(({eb, fn}) =>
eb(fn.count('id'), '>=', Threshold.MAX_ACCOUNT_DAILY_PASSWORD_RESETS).as('res')
)
.executeTakeFirstOrThrow(),
pg
.selectFrom('PasswordResetRequest')
.where('ip', '=', ip)
.where('time', '>=', yesterday)
.select(({eb, fn}) =>
eb(fn.count('id'), '>=', Threshold.MAX_DAILY_PASSWORD_RESETS).as('res')
)
.executeTakeFirstOrThrow()
])
if (failOnAccount.res || failOnTime.res) {
return {error: {message: AuthenticationError.EXCEEDED_RESET_THRESHOLD}}
}
const domain = getSSODomainFromEmail(email)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import base64url from 'base64url'
import crypto from 'crypto'
import {AuthenticationError} from 'parabol-client/types/constEnums'
import {r} from 'rethinkdb-ts'
import util from 'util'
import AuthIdentity from '../../../database/types/AuthIdentity'
import PasswordResetRequest from '../../../database/types/PasswordResetRequest'
import getMailManager from '../../../email/getMailManager'
import resetPasswordEmailCreator from '../../../email/resetPasswordEmailCreator'
import getKysely from '../../../postgres/getKysely'
import updateUser from '../../../postgres/queries/updateUser'

const randomBytes = util.promisify(crypto.randomBytes)
Expand All @@ -17,19 +16,25 @@ const processEmailPasswordReset = async (
identities: AuthIdentity[],
userId: string
) => {
const pg = getKysely()
const tokenBuffer = await randomBytes(48)
const resetPasswordToken = base64url.encode(tokenBuffer)
// invalidate all other tokens for this email
await r
.table('PasswordResetRequest')
.getAll(email, {index: 'email'})
.filter({isValid: true})
.update({isValid: false})
.run()
await r
.table('PasswordResetRequest')
.insert(new PasswordResetRequest({ip, email, token: resetPasswordToken}))
.run()
await pg
.with('InvalidateOtherTokens', (qb) =>
qb
.updateTable('PasswordResetRequest')
.set({isValid: false})
.where('email', '=', email)
.where('isValid', '=', true)
)
.insertInto('PasswordResetRequest')
.values({
ip,
email,
token: resetPasswordToken
})
.execute()

await updateUser({identities}, userId)

Expand Down
21 changes: 11 additions & 10 deletions packages/server/graphql/mutations/resetPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ 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 getRethink from '../../database/rethinkDriver'
import AuthIdentityLocal from '../../database/types/AuthIdentityLocal'
import AuthToken from '../../database/types/AuthToken'
import PasswordResetRequest from '../../database/types/PasswordResetRequest'
import getKysely from '../../postgres/getKysely'
import {getUserByEmail} from '../../postgres/queries/getUsersByEmails'
import updateUser from '../../postgres/queries/updateUser'
Expand Down Expand Up @@ -39,13 +37,11 @@ const resetPassword = {
return {error: {message: 'Resetting password is disabled'}}
}
const pg = getKysely()
const r = await getRethink()
const resetRequest = (await r
.table('PasswordResetRequest')
.getAll(token, {index: 'token'})
.nth(0)
.default(null)
.run()) as PasswordResetRequest
const resetRequest = await pg
.selectFrom('PasswordResetRequest')
.selectAll()
.where('token', '=', token)
.executeTakeFirst()

if (!resetRequest) {
return {error: {message: 'Invalid reset token'}}
Expand All @@ -69,7 +65,12 @@ const resetPassword = {
if (!localIdentity) {
return standardError(new Error(`User ${email} does not have a local identity`), {userId})
}
await r.table('PasswordResetRequest').get(resetRequestId).update({isValid: false}).run()
await pg
.updateTable('PasswordResetRequest')
.set({isValid: false})
.where('id', '=', resetRequestId)
.execute()

// MUTATIVE
localIdentity.hashedPassword = await bcrypt.hash(newPassword, Security.SALT_ROUNDS)
localIdentity.isEmailVerified = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {Kysely, PostgresDialect, sql} from 'kysely'
import {Client} from 'pg'
import {r} from 'rethinkdb-ts'
import connectRethinkDB from '../../database/connectRethinkDB'
import getPg from '../getPg'
import getPgConfig from '../getPgConfig'

export async function up() {
await connectRethinkDB()
const pg = new Kysely<any>({
dialect: new PostgresDialect({
pool: getPg()
})
})
await sql`
DO $$
BEGIN
CREATE TABLE IF NOT EXISTS "PasswordResetRequest" (
"id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"ip" cidr NOT NULL,
"email" "citext" NOT NULL,
"time" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
"token" VARCHAR(64) NOT NULL,
"isValid" BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS "idx_PasswordResetRequest_ip" ON "PasswordResetRequest"("ip");
CREATE INDEX IF NOT EXISTS "idx_PasswordResetRequest_email" ON "PasswordResetRequest"("email");
CREATE INDEX IF NOT EXISTS "idx_PasswordResetRequest_token" ON "PasswordResetRequest"("token");
END $$;
`.execute(pg)

const rRequests = await r.table('PasswordResetRequest').coerceTo('array').run()

await Promise.all(
rRequests.map(async (row) => {
const {ip, email, time, token, isValid} = row
try {
return await pg
.insertInto('PasswordResetRequest')
.values({
ip,
email,
time,
token,
isValid
})
.execute()
} catch (e) {
console.log(e, row)
}
})
)
}

export async function down() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
DROP TABLE IF EXISTS "PasswordResetRequest";
` /* Do undo magic */)
await client.end()
}

0 comments on commit 12315b0

Please sign in to comment.