diff --git a/packages/server/__tests__/globalSetup.ts b/packages/server/__tests__/globalSetup.ts index 6cecd76a0f3..555249a35c2 100644 --- a/packages/server/__tests__/globalSetup.ts +++ b/packages/server/__tests__/globalSetup.ts @@ -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 diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index d2ba61b2728..0a9e17ac7ce 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -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' @@ -54,10 +53,6 @@ export type RethinkSchema = { | NotificationMentioned index: 'userId' } - PasswordResetRequest: { - type: PasswordResetRequest - index: 'email' | 'ip' | 'token' - } PushInvitation: { type: PushInvitation index: 'userId' diff --git a/packages/server/database/types/PasswordResetRequest.ts b/packages/server/database/types/PasswordResetRequest.ts deleted file mode 100644 index baa8187c571..00000000000 --- a/packages/server/database/types/PasswordResetRequest.ts +++ /dev/null @@ -1,28 +0,0 @@ -import generateUID from '../../generateUID' - -interface Input { - id?: string - ip: string - isValid?: boolean - email: string - token: string - time?: Date -} - -export default class PasswordResetRequest { - id: string - ip: string - email: string - time: Date - token: string - isValid: boolean - constructor(input: Input) { - const {id, email, ip, isValid, time, token} = input - this.id = id ?? generateUID() - this.email = email - this.ip = ip - this.time = time ?? new Date() - this.token = token - this.isValid = isValid ?? true - } -} diff --git a/packages/server/graphql/mutations/emailPasswordReset.ts b/packages/server/graphql/mutations/emailPasswordReset.ts index 65ab85d1a7c..75a994d0a4d 100644 --- a/packages/server/graphql/mutations/emailPasswordReset.ts +++ b/packages/server/graphql/mutations/emailPasswordReset.ts @@ -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' @@ -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) diff --git a/packages/server/graphql/mutations/helpers/processEmailPasswordReset.ts b/packages/server/graphql/mutations/helpers/processEmailPasswordReset.ts index 9ee0d7aef8e..f9b94215737 100644 --- a/packages/server/graphql/mutations/helpers/processEmailPasswordReset.ts +++ b/packages/server/graphql/mutations/helpers/processEmailPasswordReset.ts @@ -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) @@ -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) diff --git a/packages/server/graphql/mutations/resetPassword.ts b/packages/server/graphql/mutations/resetPassword.ts index d90523d3cfc..89f82b8ac64 100644 --- a/packages/server/graphql/mutations/resetPassword.ts +++ b/packages/server/graphql/mutations/resetPassword.ts @@ -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' @@ -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'}} @@ -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 diff --git a/packages/server/postgres/migrations/1725996598345_PasswordResetRequest.ts b/packages/server/postgres/migrations/1725996598345_PasswordResetRequest.ts new file mode 100644 index 00000000000..b949a622405 --- /dev/null +++ b/packages/server/postgres/migrations/1725996598345_PasswordResetRequest.ts @@ -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({ + 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() +}