From 44d8add232c89d7aaa7540def10e8d49cc11c236 Mon Sep 17 00:00:00 2001 From: Muhammed Mustafa Date: Fri, 6 Oct 2023 16:06:42 +0300 Subject: [PATCH] feat(api): add update vaildate email endpoint (#50276) Co-authored-by: Oliver Eyton-Williams Co-authored-by: mot01 --- api/package.json | 1 + api/src/routes/settings.test.ts | 186 +++++++++++++++++++++++++++++++- api/src/routes/settings.ts | 128 ++++++++++++++++++++++ api/src/schemas.ts | 19 ++++ pnpm-lock.yaml | 43 ++++---- 5 files changed, 354 insertions(+), 23 deletions(-) diff --git a/api/package.json b/api/package.json index b458537f9c8..1c5aa9249c8 100644 --- a/api/package.json +++ b/api/package.json @@ -17,6 +17,7 @@ "ajv": "8.12.0", "ajv-formats": "2.1.1", "connect-mongo": "4.6.0", + "date-fns": "2.30.0", "dotenv": "16.3.1", "fast-uri": "2.2.0", "fastify": "4.21.0", diff --git a/api/src/routes/settings.test.ts b/api/src/routes/settings.test.ts index 02308958bfe..9d79c89bfda 100644 --- a/api/src/routes/settings.test.ts +++ b/api/src/routes/settings.test.ts @@ -1,4 +1,6 @@ import { devLogin, setupServer, superRequest } from '../../jest.utils'; +import { defaultUser } from '../utils/default-user'; + import { isPictureWithProtocol } from './settings'; const baseProfileUI = { @@ -24,6 +26,11 @@ const profileUI = { showPortfolio: true }; +const developerUserEmail = 'foo@bar.com'; +const otherDeveloperUserEmail = 'bar@bar.com'; +const unusedEmailOne = 'nobody@would.com'; +const unusedEmailTwo = 'would@they.com'; + const updateErrorResponse = { type: 'danger', message: 'flash.wrong-updating' @@ -92,9 +99,22 @@ describe('settingRoutes', () => { // profileUI, but we're interested in how the profileUI is updated. As // such, setting this explicitly isolates these tests. await fastifyTestInstance.prisma.user.updateMany({ - where: { email: 'foo@bar.com' }, + where: { email: developerUserEmail }, data: { profileUI: baseProfileUI } }); + + const otherUser = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: otherDeveloperUserEmail } + }); + + if (!otherUser) { + await fastifyTestInstance.prisma.user.create({ + data: { + ...defaultUser, + email: otherDeveloperUserEmail + } + }); + } }); describe('/update-my-profileui', () => { @@ -107,7 +127,7 @@ describe('settingRoutes', () => { }); const user = await fastifyTestInstance.prisma.user.findFirst({ - where: { email: 'foo@bar.com' } + where: { email: developerUserEmail } }); expect(response.body).toEqual({ @@ -130,7 +150,7 @@ describe('settingRoutes', () => { }); const user = await fastifyTestInstance.prisma.user.findFirst({ - where: { email: 'foo@bar.com' } + where: { email: developerUserEmail } }); expect(user?.profileUI).toEqual(profileUI); @@ -156,6 +176,166 @@ describe('settingRoutes', () => { }); }); + describe('/update-my-email', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: developerUserEmail }, + data: { + newEmail: null, + emailVerified: true, + emailVerifyTTL: null, + emailAuthLinkTTL: null + } + }); + }); + test('PUT returns 200 status code with "success" message', async () => { + const response = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: 'foo@foo.com' }); + + expect(response?.body).toEqual({ + message: 'flash.email-valid', + type: 'success' + }); + expect(response?.statusCode).toEqual(200); + }); + + test("PUT updates the user's record in preparation for receiving auth email", async () => { + const response = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: unusedEmailOne }); + + const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: developerUserEmail }, + select: { emailVerifyTTL: true, emailVerified: true, newEmail: true } + }); + const emailVerifyTTL = user?.emailVerifyTTL; + expect(emailVerifyTTL).toBeTruthy(); + // This throw is to mollify TS (if this is necessary a lot, create a + // helper) + if (!emailVerifyTTL) { + throw new Error('emailVerifyTTL is not defined'); + } + + expect(response?.statusCode).toEqual(200); + + // expect the emailVerifyTTL to be within 10 seconds of the current time + const tenSeconds = 10 * 1000; + expect(emailVerifyTTL.getTime()).toBeGreaterThan( + Date.now() - tenSeconds + ); + expect(emailVerifyTTL.getTime()).toBeLessThan(Date.now() + tenSeconds); + + expect(user?.emailVerified).toEqual(false); + expect(user?.newEmail).toEqual(unusedEmailOne); + }); + + test('PUT rejects invalid email addresses', async () => { + const response = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: 'invalid' }); + + // We cannot use fastify's default validation failure response here + // because the client consumes the response and displays it to the user. + expect(response?.body).toEqual({ + type: 'danger', + message: 'Email format is invalid' + }); + expect(response?.statusCode).toEqual(400); + }); + + test('PUT accepts requests to update to the current email address (ignoring case) if it is not verified', async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: developerUserEmail }, + data: { emailVerified: false } + }); + const response = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: developerUserEmail.toUpperCase() }); + + expect(response?.statusCode).toEqual(200); + expect(response?.body).toEqual({ + message: 'flash.email-valid', + type: 'success' + }); + }); + + test('PUT rejects a request to update to the existing email (ignoring case) address', async () => { + const response = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: developerUserEmail.toUpperCase() }); + + expect(response?.body).toEqual({ + type: 'info', + message: `${developerUserEmail} is already associated with this account. +You can update a new email address instead.` + }); + expect(response?.statusCode).toEqual(400); + }); + + test('PUT rejects a request to update to the same email (ignoring case) twice', async () => { + const successResponse = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: unusedEmailOne }); + + expect(successResponse?.statusCode).toEqual(200); + + const failResponse = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: unusedEmailOne.toUpperCase() }); + + expect(failResponse?.body).toEqual({ + type: 'info', + message: `We have already sent an email confirmation request to ${unusedEmailOne}. +Please wait 5 minutes to resend an authentication link.` + }); + expect(failResponse?.statusCode).toEqual(429); + }); + + test('PUT rejects a request if the new email is already in use', async () => { + const response = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: otherDeveloperUserEmail }); + + expect(response?.body).toEqual({ + type: 'info', + message: `${otherDeveloperUserEmail} is already associated with another account.` + }); + expect(response?.statusCode).toEqual(400); + }); + + test('PUT rejects the second request if is immediately after the first', async () => { + const successResponse = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: unusedEmailOne }); + + expect(successResponse?.statusCode).toEqual(200); + + const failResponse = await superRequest('/update-my-email', { + method: 'PUT', + setCookies + }).send({ email: unusedEmailTwo }); + + expect(failResponse?.statusCode).toEqual(429); + + expect(failResponse?.body).toEqual({ + type: 'info', + message: `Please wait 5 minutes to resend an authentication link.` + }); + }); + + // TODO: test that the correct email gets sent + }); + describe('/update-my-theme', () => { test('PUT returns 200 status code with "success" message', async () => { const response = await superRequest('/update-my-theme', { diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts index 4013abb450c..cf72e2cb7fe 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -1,11 +1,38 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; +import { getMinutes, isBefore, sub } from 'date-fns'; import { isProfane } from 'no-profanity'; import { blocklistedUsernames } from '../../../shared/config/constants'; import { isValidUsername } from '../../../shared/utils/validate'; import { schemas } from '../schemas'; +// TODO: move getWaitMessage and getWaitPeriod to own module and add tests +function getWaitMessage(lastEmailSentAt: Date | null) { + const minutesLeft = getWaitPeriod(lastEmailSentAt); + if (minutesLeft <= 0) { + return null; + } + + const timeToWait = minutesLeft + ? `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` + : 'a few seconds'; + + return `Please wait ${timeToWait} to resend an authentication link.`; +} + +function getWaitPeriod(lastEmailSentAt: Date | null) { + if (!lastEmailSentAt) return 0; + + const now = new Date(); + const fiveMinutesAgo = sub(now, { minutes: 5 }); + const isWaitPeriodOver = isBefore(lastEmailSentAt, fiveMinutesAgo); + + return isWaitPeriodOver + ? 0 + : 5 - (getMinutes(now) - getMinutes(lastEmailSentAt)); +} + /** * Validate an image url. * @@ -92,6 +119,107 @@ export const settingRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.put( + '/update-my-email', + { + schema: schemas.updateMyEmail, + // We need to customize the responses to validation failures: + attachValidation: true + }, + async (req, reply) => { + if (req.validationError) { + void reply.code(400); + return { message: 'Email format is invalid', type: 'danger' } as const; + } + + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: req.session.user.id }, + select: { + email: true, + emailVerifyTTL: true, + newEmail: true, + emailVerified: true, + emailAuthLinkTTL: true + } + }); + const newEmail = req.body.email.toLowerCase(); + const currentEmailFormatted = user.email.toLowerCase(); + const isVerifiedEmail = user.emailVerified; + const isOwnEmail = newEmail === currentEmailFormatted; + if (isOwnEmail && isVerifiedEmail) { + void reply.code(400); + return { + type: 'info', + message: `${newEmail} is already associated with this account. +You can update a new email address instead.` + } as const; + } + + const isResendUpdateToSameEmail = + newEmail === user.newEmail?.toLowerCase(); + const isLinkSentWithinLimitTTL = getWaitMessage(user.emailVerifyTTL); + + if (isResendUpdateToSameEmail && isLinkSentWithinLimitTTL) { + void reply.code(429); + return { + type: 'info', + message: `We have already sent an email confirmation request to ${newEmail}. +${isLinkSentWithinLimitTTL}` + } as const; + } + + const isEmailAlreadyTaken = + (await fastify.prisma.user.count({ where: { email: newEmail } })) > 0; + + if (isEmailAlreadyTaken && !isOwnEmail) { + void reply.code(400); + return { + type: 'info', + message: `${newEmail} is already associated with another account.` + } as const; + } + + // ToDo(MVP): email the new email and wait user to confirm it, before we update the user schema. + try { + await fastify.prisma.user.update({ + where: { id: req.session.user.id }, + data: { + newEmail, + emailVerified: false, + emailVerifyTTL: new Date() + } + }); + + // TODO: combine emailVerifyTTL and emailAuthLinkTTL? I'm not sure why + // we need emailVeriftyTTL given that the main thing we want is to + // restrict the rate of attempts and the emailAuthLinkTTL already does + // that. + const tooManyRequestsMessage = getWaitMessage(user.emailAuthLinkTTL); + + if (tooManyRequestsMessage) { + void reply.code(429); + return { + type: 'info', + message: tooManyRequestsMessage + } as const; + } + + await fastify.prisma.user.update({ + where: { id: req.session.user.id }, + data: { + emailAuthLinkTTL: new Date() + } + }); + + return { message: 'flash.email-valid', type: 'success' } as const; + } catch (err) { + fastify.log.error(err); + void reply.code(500); + return { message: 'flash.wrong-updating', type: 'danger' } as const; + } + } + ); + fastify.put( '/update-my-theme', { diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 069dd1ff8fd..c17a1c72b4e 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -216,6 +216,25 @@ export const schemas = { }) } }, + updateMyEmail: { + body: Type.Object({ + email: Type.String({ format: 'email', maxLength: 1024 }) + }), + response: { + 200: Type.Object({ + message: Type.Literal('flash.email-valid'), + type: Type.Literal('success') + }), + '4xx': Type.Object({ + message: Type.String(), + type: Type.Union([Type.Literal('danger'), Type.Literal('info')]) + }), + 500: Type.Object({ + message: Type.Literal('flash.wrong-updating'), + type: Type.Literal('danger') + }) + } + }, // User: deleteMyAccount: { response: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 706ae6ccb8e..a87d49d4cf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: connect-mongo: specifier: 4.6.0 version: 4.6.0(express-session@1.17.3)(mongodb@4.17.1) + date-fns: + specifier: 2.30.0 + version: 2.30.0 dotenv: specifier: 16.3.1 version: 16.3.1 @@ -6452,7 +6455,7 @@ packages: /@redux-saga/core@1.2.3: resolution: {integrity: sha512-U1JO6ncFBAklFTwoQ3mjAeQZ6QGutsJzwNBjgVLSWDpZTRhobUzuVDS1qH3SKGJD8fvqoaYOjp6XJ3gCmeZWgA==} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 '@redux-saga/deferred': 1.2.1 '@redux-saga/delay-p': 1.2.1 '@redux-saga/is': 1.1.3 @@ -11040,7 +11043,7 @@ packages: /babel-plugin-macros@2.8.0: resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 cosmiconfig: 6.0.0 resolve: 1.22.6 @@ -11173,7 +11176,7 @@ packages: gatsby: ^3.0.0-next.0 dependencies: '@babel/core': 7.23.0 - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 '@babel/types': 7.23.0 gatsby: 3.15.0(@types/node@20.8.0)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@4.9.5)(webpack-cli@4.10.0) gatsby-core-utils: 2.15.0 @@ -11657,7 +11660,7 @@ packages: '@babel/plugin-transform-spread': 7.22.5(@babel/core@7.23.0) '@babel/preset-env': 7.22.20(@babel/core@7.23.0) '@babel/preset-react': 7.22.15(@babel/core@7.23.0) - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 babel-plugin-dynamic-import-node: 2.3.3 babel-plugin-macros: 2.8.0 babel-plugin-transform-react-remove-prop-types: 0.4.24 @@ -13379,7 +13382,7 @@ packages: resolution: {integrity: sha512-l6U4w4h8uGwKH92I8lopiY9hpa6BPN8IsTm06VwfG4U7xZH1HI5KuP7MVO126cCy+iG482cerhMFE/kTJ/J0Jw==} hasBin: true dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 /create-hash@1.2.0: resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} @@ -14449,13 +14452,13 @@ packages: /dom-helpers@3.4.0: resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 dev: false /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 csstype: 3.1.2 dev: false @@ -15248,7 +15251,7 @@ packages: peerDependencies: graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 graphql: 15.8.0 graphql-config: 3.4.1(@types/node@20.8.0)(graphql@15.8.0)(typescript@4.9.5) lodash.flatten: 4.4.0 @@ -16943,7 +16946,7 @@ packages: resolution: {integrity: sha512-QspRxfSgD4Yb5syp/yNPN+ljXgatfgqq4/TIIJw5mVxVMhNenb8mQ8ihVL5vdhV7x3wUjKTwVIjZ+eU/sMLz7g==} engines: {node: '>=12.13.0'} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 ci-info: 2.0.0 configstore: 5.0.1 file-type: 16.5.4 @@ -16958,12 +16961,12 @@ packages: resolution: {integrity: sha512-w9QSjOBJKi8nADypZycRBNEzRHyBEERUbMygMHEa/Lr8JkWi5YV5TplneDnJv9/R4VXiKA3CcpbIrpGqzp7qmA==} engines: {node: '>=12.13.0'} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 /gatsby-legacy-polyfills@1.15.0: resolution: {integrity: sha512-CysOx6kjH7jomkhUdMDy5oFPC0vfdHfh96O+ZENW5tk9k4SQ+G64weywm9EfC6coIFPYytBhW/SrE7ZwizKnPA==} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 core-js-compat: 3.9.0 /gatsby-link@3.15.0(@gatsbyjs/reach-router@1.3.9)(react-dom@16.14.0)(react@16.14.0): @@ -16985,7 +16988,7 @@ packages: resolution: {integrity: sha512-Nj5lcMJaACCaWVgRc6T45U7KxhqEPpWZo/IlOaCdX2pWjK/5oWI3hVtfukfFpDRunYtIdw2Vl8bicLgU+viAWA==} engines: {node: '>=12.13.0'} dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 bluebird: 3.7.2 chokidar: 3.5.3 fs-exists-cached: 1.0.0 @@ -17041,7 +17044,7 @@ packages: peerDependencies: gatsby: ^3.0.0-next.0 dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 '@babel/traverse': 7.23.0 '@sindresorhus/slugify': 1.1.2 chokidar: 3.5.3 @@ -17111,7 +17114,7 @@ packages: '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.23.0) '@babel/plugin-proposal-optional-chaining': 7.17.12(@babel/core@7.23.0) '@babel/preset-typescript': 7.23.0(@babel/core@7.23.0) - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 babel-plugin-remove-graphql-queries: 3.15.0(@babel/core@7.23.0)(gatsby@3.15.0) gatsby: 3.15.0(@types/node@20.8.0)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@4.9.5)(webpack-cli@4.10.0) transitivePeerDependencies: @@ -17124,7 +17127,7 @@ packages: gatsby: ^3.0.0-next.0 graphql: ^15.0.0 dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 fastq: 1.15.0 gatsby: 3.15.0(@types/node@20.8.0)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@4.9.5)(webpack-cli@4.10.0) graphql: 15.8.0 @@ -17151,7 +17154,7 @@ packages: react: ^16.9.0 || ^17.0.0 react-dom: ^16.9.0 || ^17.0.0 dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 '@gatsbyjs/reach-router': 1.3.9(react-dom@16.14.0)(react@16.14.0) prop-types: 15.8.1 react: 16.14.0 @@ -17165,7 +17168,7 @@ packages: '@babel/helper-plugin-utils': 7.22.5 '@babel/plugin-proposal-optional-chaining': 7.17.12(@babel/core@7.23.0) '@babel/plugin-transform-react-jsx': 7.22.15(@babel/core@7.23.0) - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 '@babel/standalone': 7.23.1 '@babel/template': 7.22.15 '@babel/types': 7.23.0 @@ -17267,7 +17270,7 @@ packages: requiresBuild: true dependencies: '@babel/code-frame': 7.22.13 - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 '@turist/fetch': 7.2.0(node-fetch@2.7.0) '@turist/time': 0.0.2 async-retry-ng: 2.0.1 @@ -17321,7 +17324,7 @@ packages: engines: {node: '>=12.13.0'} dependencies: '@babel/core': 7.23.0 - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 transitivePeerDependencies: - supports-color @@ -25439,7 +25442,7 @@ packages: react: '>0.13.0' react-dom: '>0.13.0' dependencies: - '@babel/runtime': 7.20.13 + '@babel/runtime': 7.23.1 get-node-dimensions: 1.2.1 prop-types: 15.8.1 react: 16.14.0