From 4c2f9e1f18cffa477fa49e31f53ccc7a1206d652 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Wed, 29 Nov 2023 13:26:32 -0300 Subject: [PATCH 01/10] feat: Reset password flow --- .env.example | 2 + package-lock.json | 38 +++++++-- package.json | 3 + .../migration.sql | 21 +++++ prisma/schema.prisma | 18 +++++ src/config/config.ts | 8 ++ src/config/errors.ts | 10 +++ src/controllers/session.ts | 29 +++++++ src/emails/index.ts | 52 +++++++++--- src/emails/resetPasswordCodeTemplate.pug | 6 ++ .../{template.pug => signUpTemplate.pug} | 0 src/services/session.ts | 79 +++++++++++++++++++ src/types/config.ts | 1 + src/types/index.ts | 1 + src/types/session.ts | 9 +++ src/utils/hash.ts | 42 ++++++++++ 16 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20231128184544_add_hash_table/migration.sql create mode 100644 src/controllers/session.ts create mode 100644 src/emails/resetPasswordCodeTemplate.pug rename src/emails/{template.pug => signUpTemplate.pug} (100%) create mode 100644 src/services/session.ts create mode 100644 src/types/session.ts create mode 100644 src/utils/hash.ts diff --git a/.env.example b/.env.example index 12c09bc..e019b93 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,5 @@ REDIS_PASSWORD='' REDIS_PORT=6379 REDIS_USERNAME='' JOBS_RETENTION_HOURS=24 + +OTP_EXPIRATION_TIME=15 diff --git a/package-lock.json b/package-lock.json index 6458a15..afe9c66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "compression": "^1.7.4", "cors": "^2.8.5", "cross-spawn": "^7.0.3", + "date-fns": "^2.30.0", "dotenv": "^16.0.0", "dotenv-cli": "^7.3.0", "express": "^4.17.1", @@ -31,6 +32,7 @@ "node-cron": "^3.0.2", "nodemailer": "^6.9.4", "nodemon": "^3.0.1", + "otp-generator": "^4.0.1", "prisma": "^5.5.2", "pug": "^3.0.2", "swagger-ui-express": "^5.0.0", @@ -52,6 +54,7 @@ "@types/node": "^18.17.1", "@types/node-cron": "^3.0.8", "@types/nodemailer": "^6.4.9", + "@types/otp-generator": "^4.0.2", "@types/pug": "^2.0.6", "@types/swagger-ui-express": "^4.1.3", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -589,7 +592,6 @@ "version": "7.22.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -1730,6 +1732,12 @@ "@types/node": "*" } }, + "node_modules/@types/otp-generator": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/otp-generator/-/otp-generator-4.0.2.tgz", + "integrity": "sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==", + "dev": true + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -3068,7 +3076,6 @@ "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -6692,6 +6699,14 @@ "node": ">=0.10.0" } }, + "node_modules/otp-generator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/otp-generator/-/otp-generator-4.0.1.tgz", + "integrity": "sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==", + "engines": { + "node": ">=14.10.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7269,8 +7284,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", @@ -9018,7 +9032,6 @@ "version": "7.22.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.11" } @@ -9920,6 +9933,12 @@ "@types/node": "*" } }, + "@types/otp-generator": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/otp-generator/-/otp-generator-4.0.2.tgz", + "integrity": "sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==", + "dev": true + }, "@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -10880,7 +10899,6 @@ "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, "requires": { "@babel/runtime": "^7.21.0" } @@ -13526,6 +13544,11 @@ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" }, + "otp-generator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/otp-generator/-/otp-generator-4.0.1.tgz", + "integrity": "sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==" + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13948,8 +13971,7 @@ "regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "regexp.prototype.flags": { "version": "1.5.0", diff --git a/package.json b/package.json index ed12811..69123b7 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/node": "^18.17.1", "@types/node-cron": "^3.0.8", "@types/nodemailer": "^6.4.9", + "@types/otp-generator": "^4.0.2", "@types/pug": "^2.0.6", "@types/swagger-ui-express": "^4.1.3", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -70,6 +71,7 @@ "concurrently": "^8.2.0", "cors": "^2.8.5", "cross-spawn": "^7.0.3", + "date-fns": "^2.30.0", "dotenv": "^16.0.0", "dotenv-cli": "^7.3.0", "express": "^4.17.1", @@ -85,6 +87,7 @@ "node-cron": "^3.0.2", "nodemailer": "^6.9.4", "nodemon": "^3.0.1", + "otp-generator": "^4.0.1", "prisma": "^5.5.2", "pug": "^3.0.2", "swagger-ui-express": "^5.0.0", diff --git a/prisma/migrations/20231128184544_add_hash_table/migration.sql b/prisma/migrations/20231128184544_add_hash_table/migration.sql new file mode 100644 index 0000000..21fdae0 --- /dev/null +++ b/prisma/migrations/20231128184544_add_hash_table/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "TypeHash" AS ENUM ('RESET_PASSWORD'); + +-- CreateTable +CREATE TABLE "Hash" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "hash" TEXT NOT NULL, + "type" "TypeHash" NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "user_id" TEXT, + + CONSTRAINT "Hash_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Hash_user_id_type_key" ON "Hash"("user_id", "type"); + +-- AddForeignKey +ALTER TABLE "Hash" ADD CONSTRAINT "Hash_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a35d270..b57d2db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,7 @@ model User { email String @unique password String name String? + hash Hash[] } model Session { @@ -28,3 +29,20 @@ model Session { accessToken String @unique refreshToken String @unique } + +model Hash { + id String @id @default(uuid()) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamp(6) + hash String + type TypeHash + expiresAt DateTime @map("expires_at") + userId String? @map("user_id") + user User? @relation(fields: [userId], references: [id]) + + @@unique([userId, type]) +} + +enum TypeHash { + RESET_PASSWORD +} diff --git a/src/config/config.ts b/src/config/config.ts index 85b156d..d91c836 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -47,6 +47,13 @@ const envVarsSchema = z 'JOBS RETENTION HOURS must be a number', ), REDIS_USERNAME: z.string(), + OTP_EXPIRATION_TIME: z + .string() + .transform((val) => Number(val)) + .refine( + (val) => !Number.isNaN(val), + 'OTP EXPIRATION TIME must be a number', + ), }) .passthrough(); @@ -76,4 +83,5 @@ export const config: Config = { redisPort: envVars.REDIS_PORT, redisUsername: envVars.REDIS_USERNAME, jobsRetentionHours: envVars.JOBS_RETENTION_HOURS, + otpExpirationTime: envVars.OTP_EXPIRATION_TIME, }; diff --git a/src/config/errors.ts b/src/config/errors.ts index ddff85a..cfb1c63 100644 --- a/src/config/errors.ts +++ b/src/config/errors.ts @@ -19,6 +19,11 @@ export const errors = { errorCode: 400_003, description: 'Invalid token', }, + INVALID_CODE: { + httpCode: 400, + errorCode: 400_004, + description: 'Invalid code', + }, UNAUTHENTICATED: { httpCode: 401, errorCode: 401_000, @@ -29,6 +34,11 @@ export const errors = { errorCode: 401_001, description: 'Token expired', }, + CODE_EXPIRED: { + httpCode: 403, + errorCode: 403_001, + description: 'Code has expired', + }, NOT_FOUND: { httpCode: 404, errorCode: 404_000, diff --git a/src/controllers/session.ts b/src/controllers/session.ts new file mode 100644 index 0000000..38b2c40 --- /dev/null +++ b/src/controllers/session.ts @@ -0,0 +1,29 @@ +import httpStatus from 'http-status'; +import { + Body, + Controller, + Post, + Route, +} from 'tsoa'; +import { PasswordResetCodeRequest, ResetPassword } from 'types'; +import { SessionService } from 'services/session'; + +@Route('v1/session') +export class SessionControllerV1 extends Controller { + @Post('/requestResetPasswordCode') + public async requestResetPasswordCode( + @Body() requestBody: PasswordResetCodeRequest, + ): Promise { + await SessionService.requestResetPasswordCode(requestBody.email); + this.setStatus(httpStatus.OK); + } + + @Post('/resetPassword') + public async resetPassword( + @Body() requestBody: ResetPassword, + ): Promise { + const {email, code, newPassword} = requestBody; + await SessionService.resetPassword(email, code, newPassword); + this.setStatus(httpStatus.OK); + } +} diff --git a/src/emails/index.ts b/src/emails/index.ts index 61ad738..acdd92a 100644 --- a/src/emails/index.ts +++ b/src/emails/index.ts @@ -3,8 +3,7 @@ import pug from 'pug'; import { config } from 'config/config'; -const createTransporter = () => { - const testTransporter = nodemailer.createTransport({ +const emailTransporter = nodemailer.createTransport({ host: config.smtpHost, port: config.smtpPort, auth: { @@ -12,20 +11,13 @@ const createTransporter = () => { pass: config.smtpPassword, }, }); - return testTransporter; -}; -export async function sendSignUpEmail( - appName: string, +const sendEmail = async ( emailTo: string, -): Promise { - const subject = ` Welcome to ${appName}!!`; - const html = pug.renderFile('src/emails/template.pug', { - appName, - username: emailTo, - }); + subject: string, + html: string, +): Promise => { - const emailTransporter = createTransporter(); await emailTransporter.sendMail({ from: config.emailFrom, to: emailTo, @@ -33,3 +25,37 @@ export async function sendSignUpEmail( html, }); } + +export async function sendSignUpEmail( + emailTo: string, +): Promise { + const subject = `Welcome to ${config.appName}!!`; + const html = pug.renderFile('src/emails/signUpTemplate.pug', { + appName: config.appName, + username: emailTo, + }); + + await sendEmail( + emailTo, + subject, + html, + ); +} + +export const sendResetPasswordCode = async ( + emailTo: string, + code: string, +): Promise => { + const subject = `${config.appName} password recovery code`; + const html = pug.renderFile('src/emails/resetPasswordCodeTemplate.pug', { + appName: config.appName, + username: emailTo, + code, + }); + + await sendEmail( + emailTo, + subject, + html + ); +}; diff --git a/src/emails/resetPasswordCodeTemplate.pug b/src/emails/resetPasswordCodeTemplate.pug new file mode 100644 index 0000000..21e79aa --- /dev/null +++ b/src/emails/resetPasswordCodeTemplate.pug @@ -0,0 +1,6 @@ +html + head + title #{appName} password reset code + body + h1 Hello #{username}, + p To recover your password use the next code #{code} diff --git a/src/emails/template.pug b/src/emails/signUpTemplate.pug similarity index 100% rename from src/emails/template.pug rename to src/emails/signUpTemplate.pug diff --git a/src/services/session.ts b/src/services/session.ts new file mode 100644 index 0000000..a13eef4 --- /dev/null +++ b/src/services/session.ts @@ -0,0 +1,79 @@ +import * as bcrypt from 'bcryptjs'; +import { addMinutes } from 'date-fns'; + +import { ApiError } from 'utils/apiError'; +import { errors } from 'config/errors'; +import { emailRegex } from 'utils/constants'; +import { generateCodeAndHash, verifyHash } from 'utils/hash'; +import prisma from 'root/prisma/client'; +import { TypeHash } from '@prisma/client'; +import { config } from 'config/config'; +import { sendResetPasswordCode } from 'emails'; + +export class SessionService { + static requestResetPasswordCode = async (email: string) => { + if (!emailRegex.test(email)) { + throw new ApiError(errors.INVALID_EMAIL); + } + + const user = await prisma.user.findUnique({ + where: { + email, + } + }) + if (!user) throw new ApiError(errors.INVALID_EMAIL); + + const {code, hash} = await generateCodeAndHash(); + const expirationDate = addMinutes(new Date(), config.otpExpirationTime); + + await prisma.hash.upsert({ + create: { + userId: user.id, + hash, + expiresAt: expirationDate, + type: TypeHash.RESET_PASSWORD + }, + update: { + hash, + expiresAt: expirationDate, + userId: user.id, + }, + where: { + userId_type: { + userId: user.id, + type: TypeHash.RESET_PASSWORD, + } + } + }); + + await sendResetPasswordCode(email, code); + }; + + static resetPassword = async ( + email: string, + code: string, + newPassword: string, + ): Promise => { + const user = await prisma.user.findUnique({ + where: { + email, + } + }) + if (!user) throw new ApiError(errors.INVALID_EMAIL); + + const hash = await verifyHash(user.id, TypeHash.RESET_PASSWORD, code); + const hashedNewPassword = await bcrypt.hash(newPassword, 8); + + try { + await prisma.$transaction([ + prisma.hash.delete({ where: { id: hash.id } }), + prisma.user.update({ + where: { id: user.id }, + data: { password: hashedNewPassword }, + }), + ]); + } catch (err) { + throw err; + } + }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 0adfc55..25e068e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -18,6 +18,7 @@ export interface Config { redisPort: number; redisUsername: string; jobsRetentionHours: number; + otpExpirationTime: number; } export interface ErrorInterface { diff --git a/src/types/index.ts b/src/types/index.ts index 15ef488..48a2816 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,3 +10,4 @@ export { ReturnAuth, LoginParams, RefreshTokenParams } from 'types/auth'; export { AuthenticatedRequest } from 'types/request'; export { EmailTypes } from 'types/emails'; export { WorkerQueues } from 'types/worker'; +export { PasswordResetCodeRequest, ResetPassword } from 'types/session'; diff --git a/src/types/session.ts b/src/types/session.ts new file mode 100644 index 0000000..6724c77 --- /dev/null +++ b/src/types/session.ts @@ -0,0 +1,9 @@ +export type PasswordResetCodeRequest = { + email: string +} + +export type ResetPassword = { + email: string, + code: string, + newPassword: string, +} diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 0000000..08ab26b --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,42 @@ +import * as bcrypt from 'bcryptjs'; +import { generate } from 'otp-generator'; + +import { TypeHash } from '@prisma/client'; +import { errors } from 'config/errors'; +import prisma from 'root/prisma/client'; +import { ApiError } from './apiError'; + +export const generateCodeAndHash = async () => { + const code = generate(6, { + specialChars: false, + lowerCaseAlphabets: false, + upperCaseAlphabets: false, + }) + + const hash = await bcrypt.hash(code, 8); + + return {code, hash}; +}; + +export const verifyHash = async ( + userId: string, + hashType: TypeHash, + code: string, +) => { + const hash = await prisma.hash.findUnique({ + where: { + userId_type: {userId, type: hashType}, + + } + }); + + if (!hash || !bcrypt.compareSync(code, hash.hash)) { + throw new ApiError(errors.INVALID_CODE); + } + + if (hash.expiresAt < new Date()) { + throw new ApiError(errors.CODE_EXPIRED); + } + + return hash; +} From 4ea1f46ee5ec8cca02eef09f8f7c56d04171f6d2 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Thu, 30 Nov 2023 15:43:41 -0300 Subject: [PATCH 02/10] Fix format --- src/controllers/session.ts | 9 ++------- src/emails/index.ts | 33 +++++++++++---------------------- src/services/session.ts | 36 ++++++++++++++++-------------------- src/types/session.ts | 12 ++++++------ src/utils/hash.ts | 11 +++++------ 5 files changed, 40 insertions(+), 61 deletions(-) diff --git a/src/controllers/session.ts b/src/controllers/session.ts index 38b2c40..1dfb459 100644 --- a/src/controllers/session.ts +++ b/src/controllers/session.ts @@ -1,10 +1,5 @@ import httpStatus from 'http-status'; -import { - Body, - Controller, - Post, - Route, -} from 'tsoa'; +import { Body, Controller, Post, Route } from 'tsoa'; import { PasswordResetCodeRequest, ResetPassword } from 'types'; import { SessionService } from 'services/session'; @@ -22,7 +17,7 @@ export class SessionControllerV1 extends Controller { public async resetPassword( @Body() requestBody: ResetPassword, ): Promise { - const {email, code, newPassword} = requestBody; + const { email, code, newPassword } = requestBody; await SessionService.resetPassword(email, code, newPassword); this.setStatus(httpStatus.OK); } diff --git a/src/emails/index.ts b/src/emails/index.ts index acdd92a..6586c18 100644 --- a/src/emails/index.ts +++ b/src/emails/index.ts @@ -4,42 +4,35 @@ import pug from 'pug'; import { config } from 'config/config'; const emailTransporter = nodemailer.createTransport({ - host: config.smtpHost, - port: config.smtpPort, - auth: { - user: config.smtpUser, - pass: config.smtpPassword, - }, - }); + host: config.smtpHost, + port: config.smtpPort, + auth: { + user: config.smtpUser, + pass: config.smtpPassword, + }, +}); const sendEmail = async ( emailTo: string, subject: string, html: string, ): Promise => { - await emailTransporter.sendMail({ from: config.emailFrom, to: emailTo, subject, html, }); -} +}; -export async function sendSignUpEmail( - emailTo: string, -): Promise { +export async function sendSignUpEmail(emailTo: string): Promise { const subject = `Welcome to ${config.appName}!!`; const html = pug.renderFile('src/emails/signUpTemplate.pug', { appName: config.appName, username: emailTo, }); - await sendEmail( - emailTo, - subject, - html, - ); + await sendEmail(emailTo, subject, html); } export const sendResetPasswordCode = async ( @@ -53,9 +46,5 @@ export const sendResetPasswordCode = async ( code, }); - await sendEmail( - emailTo, - subject, - html - ); + await sendEmail(emailTo, subject, html); }; diff --git a/src/services/session.ts b/src/services/session.ts index a13eef4..9542c5d 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -19,19 +19,19 @@ export class SessionService { const user = await prisma.user.findUnique({ where: { email, - } - }) + }, + }); if (!user) throw new ApiError(errors.INVALID_EMAIL); - const {code, hash} = await generateCodeAndHash(); + const { code, hash } = await generateCodeAndHash(); const expirationDate = addMinutes(new Date(), config.otpExpirationTime); - + await prisma.hash.upsert({ create: { userId: user.id, hash, expiresAt: expirationDate, - type: TypeHash.RESET_PASSWORD + type: TypeHash.RESET_PASSWORD, }, update: { hash, @@ -42,8 +42,8 @@ export class SessionService { userId_type: { userId: user.id, type: TypeHash.RESET_PASSWORD, - } - } + }, + }, }); await sendResetPasswordCode(email, code); @@ -57,23 +57,19 @@ export class SessionService { const user = await prisma.user.findUnique({ where: { email, - } - }) + }, + }); if (!user) throw new ApiError(errors.INVALID_EMAIL); const hash = await verifyHash(user.id, TypeHash.RESET_PASSWORD, code); const hashedNewPassword = await bcrypt.hash(newPassword, 8); - try { - await prisma.$transaction([ - prisma.hash.delete({ where: { id: hash.id } }), - prisma.user.update({ - where: { id: user.id }, - data: { password: hashedNewPassword }, - }), - ]); - } catch (err) { - throw err; - } + await prisma.$transaction([ + prisma.hash.delete({ where: { id: hash.id } }), + prisma.user.update({ + where: { id: user.id }, + data: { password: hashedNewPassword }, + }), + ]); }; } diff --git a/src/types/session.ts b/src/types/session.ts index 6724c77..bcbc173 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -1,9 +1,9 @@ export type PasswordResetCodeRequest = { - email: string -} + email: string; +}; export type ResetPassword = { - email: string, - code: string, - newPassword: string, -} + email: string; + code: string; + newPassword: string; +}; diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 08ab26b..42ca67b 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -11,11 +11,11 @@ export const generateCodeAndHash = async () => { specialChars: false, lowerCaseAlphabets: false, upperCaseAlphabets: false, - }) + }); const hash = await bcrypt.hash(code, 8); - return {code, hash}; + return { code, hash }; }; export const verifyHash = async ( @@ -25,9 +25,8 @@ export const verifyHash = async ( ) => { const hash = await prisma.hash.findUnique({ where: { - userId_type: {userId, type: hashType}, - - } + userId_type: { userId, type: hashType }, + }, }); if (!hash || !bcrypt.compareSync(code, hash.hash)) { @@ -39,4 +38,4 @@ export const verifyHash = async ( } return hash; -} +}; From 377ad0295ae0a31237480e0e4590f04a2685c7d1 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Mon, 4 Dec 2023 09:54:09 -0300 Subject: [PATCH 03/10] Update controllers to use --- src/controllers/session.ts | 24 ------------ src/controllers/users.ts | 26 ++++++++++++- src/services/session.ts | 75 -------------------------------------- src/services/user.ts | 65 ++++++++++++++++++++++++++++++++- src/types/index.ts | 3 +- src/types/session.ts | 9 ----- src/types/user.ts | 10 +++++ 7 files changed, 101 insertions(+), 111 deletions(-) delete mode 100644 src/controllers/session.ts delete mode 100644 src/services/session.ts delete mode 100644 src/types/session.ts diff --git a/src/controllers/session.ts b/src/controllers/session.ts deleted file mode 100644 index 1dfb459..0000000 --- a/src/controllers/session.ts +++ /dev/null @@ -1,24 +0,0 @@ -import httpStatus from 'http-status'; -import { Body, Controller, Post, Route } from 'tsoa'; -import { PasswordResetCodeRequest, ResetPassword } from 'types'; -import { SessionService } from 'services/session'; - -@Route('v1/session') -export class SessionControllerV1 extends Controller { - @Post('/requestResetPasswordCode') - public async requestResetPasswordCode( - @Body() requestBody: PasswordResetCodeRequest, - ): Promise { - await SessionService.requestResetPasswordCode(requestBody.email); - this.setStatus(httpStatus.OK); - } - - @Post('/resetPassword') - public async resetPassword( - @Body() requestBody: ResetPassword, - ): Promise { - const { email, code, newPassword } = requestBody; - await SessionService.resetPassword(email, code, newPassword); - this.setStatus(httpStatus.OK); - } -} diff --git a/src/controllers/users.ts b/src/controllers/users.ts index 926135a..067dcb2 100644 --- a/src/controllers/users.ts +++ b/src/controllers/users.ts @@ -5,13 +5,20 @@ import { Delete, Get, Path, + Post, Put, Request, Route, Security, } from 'tsoa'; import { UserService } from 'services'; -import { ReturnUser, UpdateUserParams, AuthenticatedRequest } from 'types'; +import { + ReturnUser, + UpdateUserParams, + AuthenticatedRequest, + PasswordResetCodeRequest, + ResetPassword, +} from 'types'; @Route('v1/users') export class UsersControllerV1 extends Controller { @@ -58,4 +65,21 @@ export class UsersControllerV1 extends Controller { await UserService.destroy(id); this.setStatus(httpStatus.NO_CONTENT); } + + @Post('/requestResetPasswordCode') + public async requestResetPasswordCode( + @Body() requestBody: PasswordResetCodeRequest, + ): Promise { + await UserService.requestResetPasswordCode(requestBody.email); + this.setStatus(httpStatus.OK); + } + + @Post('/resetPassword') + public async resetPassword( + @Body() requestBody: ResetPassword, + ): Promise { + const { email, code, newPassword } = requestBody; + await UserService.resetPassword(email, code, newPassword); + this.setStatus(httpStatus.OK); + } } diff --git a/src/services/session.ts b/src/services/session.ts deleted file mode 100644 index 9542c5d..0000000 --- a/src/services/session.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as bcrypt from 'bcryptjs'; -import { addMinutes } from 'date-fns'; - -import { ApiError } from 'utils/apiError'; -import { errors } from 'config/errors'; -import { emailRegex } from 'utils/constants'; -import { generateCodeAndHash, verifyHash } from 'utils/hash'; -import prisma from 'root/prisma/client'; -import { TypeHash } from '@prisma/client'; -import { config } from 'config/config'; -import { sendResetPasswordCode } from 'emails'; - -export class SessionService { - static requestResetPasswordCode = async (email: string) => { - if (!emailRegex.test(email)) { - throw new ApiError(errors.INVALID_EMAIL); - } - - const user = await prisma.user.findUnique({ - where: { - email, - }, - }); - if (!user) throw new ApiError(errors.INVALID_EMAIL); - - const { code, hash } = await generateCodeAndHash(); - const expirationDate = addMinutes(new Date(), config.otpExpirationTime); - - await prisma.hash.upsert({ - create: { - userId: user.id, - hash, - expiresAt: expirationDate, - type: TypeHash.RESET_PASSWORD, - }, - update: { - hash, - expiresAt: expirationDate, - userId: user.id, - }, - where: { - userId_type: { - userId: user.id, - type: TypeHash.RESET_PASSWORD, - }, - }, - }); - - await sendResetPasswordCode(email, code); - }; - - static resetPassword = async ( - email: string, - code: string, - newPassword: string, - ): Promise => { - const user = await prisma.user.findUnique({ - where: { - email, - }, - }); - if (!user) throw new ApiError(errors.INVALID_EMAIL); - - const hash = await verifyHash(user.id, TypeHash.RESET_PASSWORD, code); - const hashedNewPassword = await bcrypt.hash(newPassword, 8); - - await prisma.$transaction([ - prisma.hash.delete({ where: { id: hash.id } }), - prisma.user.update({ - where: { id: user.id }, - data: { password: hashedNewPassword }, - }), - ]); - }; -} diff --git a/src/services/user.ts b/src/services/user.ts index 0cc2eb5..4572a93 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -1,5 +1,7 @@ import * as bcrypt from 'bcryptjs'; -import { Prisma } from '@prisma/client'; +import { addMinutes } from 'date-fns'; + +import { Prisma, TypeHash } from '@prisma/client'; import prisma from 'root/prisma/client'; import { ApiError } from 'utils/apiError'; import { @@ -13,6 +15,9 @@ import { sendUserWithoutPassword } from 'utils/user'; import { emailRegex } from 'utils/constants'; import { errors } from 'config/errors'; import { addToMailQueue } from 'queue/queue'; +import { generateCodeAndHash, verifyHash } from 'utils/hash'; +import { config } from 'config/config'; +import { sendResetPasswordCode } from 'emails'; export class UserService { static find = async (id: string): Promise => { @@ -97,4 +102,62 @@ export class UserService { await prisma.user.delete({ where: { id } }); }; + + static requestResetPasswordCode = async (email: string) => { + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + if (!user) throw new ApiError(errors.INVALID_EMAIL); + + const { code, hash } = await generateCodeAndHash(); + const expirationDate = addMinutes(new Date(), config.otpExpirationTime); + + await prisma.hash.upsert({ + create: { + userId: user.id, + hash, + expiresAt: expirationDate, + type: TypeHash.RESET_PASSWORD, + }, + update: { + hash, + expiresAt: expirationDate, + userId: user.id, + }, + where: { + userId_type: { + userId: user.id, + type: TypeHash.RESET_PASSWORD, + }, + }, + }); + + await sendResetPasswordCode(email, code); + }; + + static resetPassword = async ( + email: string, + code: string, + newPassword: string, + ): Promise => { + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + if (!user) throw new ApiError(errors.INVALID_EMAIL); + + const hash = await verifyHash(user.id, TypeHash.RESET_PASSWORD, code); + const hashedNewPassword = await bcrypt.hash(newPassword, 8); + + await prisma.$transaction([ + prisma.hash.delete({ where: { id: hash.id } }), + prisma.user.update({ + where: { id: user.id }, + data: { password: hashedNewPassword }, + }), + ]); + }; } diff --git a/src/types/index.ts b/src/types/index.ts index 48a2816..eddd4e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,10 +4,11 @@ export { CreateUserParams, UpdateUserParams, DatabaseUser, + PasswordResetCodeRequest, + ResetPassword, } from 'types/user'; export { Wrapper } from 'types/middlewares'; export { ReturnAuth, LoginParams, RefreshTokenParams } from 'types/auth'; export { AuthenticatedRequest } from 'types/request'; export { EmailTypes } from 'types/emails'; export { WorkerQueues } from 'types/worker'; -export { PasswordResetCodeRequest, ResetPassword } from 'types/session'; diff --git a/src/types/session.ts b/src/types/session.ts deleted file mode 100644 index bcbc173..0000000 --- a/src/types/session.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type PasswordResetCodeRequest = { - email: string; -}; - -export type ResetPassword = { - email: string; - code: string; - newPassword: string; -}; diff --git a/src/types/user.ts b/src/types/user.ts index 44ef9c9..6953a53 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -14,3 +14,13 @@ export type ReturnUser = Omit; export type CreateUserParams = Omit; export type UpdateUserParams = Omit; + +export type PasswordResetCodeRequest = { + email: string; +}; + +export type ResetPassword = { + email: string; + code: string; + newPassword: string; +}; From e1ad0450b37764834669c70978d522169e63d48d Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Mon, 4 Dec 2023 10:24:49 -0300 Subject: [PATCH 04/10] Add test --- src/services/user.test.ts | 114 +++++++++++++++++++++++++++++++- src/tests/utils/generateData.ts | 17 ++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/services/user.test.ts b/src/services/user.test.ts index e8b387c..32a8190 100644 --- a/src/services/user.test.ts +++ b/src/services/user.test.ts @@ -1,4 +1,4 @@ -import { generateUserData } from 'tests/utils/generateData'; +import { generateHashData, generateUserData } from 'tests/utils/generateData'; import { UserService } from 'services/user'; import { sendUserWithoutPassword } from 'utils/user'; import { addToMailQueue } from 'queue/queue'; @@ -6,14 +6,27 @@ import { EmailTypes } from 'types'; import prisma from 'root/prisma/client'; import { ApiError } from 'utils/apiError'; import { errors } from 'config/errors'; +import * as bcrypt from 'bcryptjs'; + +import { prismaMock } from 'tests/prismaSetup'; +import { sendResetPasswordCode } from 'emails'; +import { faker } from '@faker-js/faker'; +import { startOfYesterday } from 'date-fns'; jest.mock('utils/user'); jest.mock('queue/queue'); const mockMailQueueAdd = addToMailQueue as jest.Mock; const mockSendUserWithoutPassword = sendUserWithoutPassword as jest.Mock; +const mockSendResetPasswordCode = sendResetPasswordCode as jest.Mock; + +const mockCode = String(Math.floor(100000 + Math.random() * 900000)); const userData = generateUserData(); +const hashData = generateHashData({ + userId: userData.id, + hash: bcrypt.hashSync(mockCode, 8), +}); describe('User service: ', () => { beforeEach(() => { @@ -51,4 +64,103 @@ describe('User service: ', () => { ); }); }); + + describe('create function', () => { + test('should create a new user with email', async () => { + prismaMock.user.create.mockResolvedValue(userData); + prismaMock.user.update.mockResolvedValue(userData); + const { password, ...userWithoutPassword } = userData; + mockSendUserWithoutPassword.mockResolvedValue(userWithoutPassword); + await expect(UserService.create(userData)).resolves.toEqual( + userWithoutPassword, + ); + }); + + test('should not create a new user', async () => { + const referenceError = new Error('something went wrong'); + + prismaMock.user.create.mockRejectedValue(referenceError); + await expect(UserService.create(userData)).rejects.toEqual( + referenceError, + ); + }); + }); + + describe('Request reset password code', () => { + test('should create and return new hash', async () => { + prismaMock.user.findUnique.mockResolvedValue(userData); + prismaMock.hash.upsert.mockResolvedValue(hashData); + mockSendResetPasswordCode.mockResolvedValue(undefined); + await expect( + UserService.requestResetPasswordCode(userData.email), + ).resolves.toEqual(undefined); + }); + + describe('invalid data', () => { + test('user with email does not exist', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + await expect( + UserService.requestResetPasswordCode(userData.email), + ).rejects.toThrow(new ApiError(errors.INVALID_EMAIL)); + }); + }); + }); + + describe('Reset Password', () => { + beforeEach(() => { + prismaMock.user.findUnique.mockResolvedValue(userData); + prismaMock.hash.findUnique.mockResolvedValue(hashData); + prismaMock.$transaction.mockResolvedValue(undefined); + }); + + test('should update the password successfully', async () => { + await expect( + UserService.resetPassword(userData.email, mockCode, userData.password), + ).resolves.toEqual(undefined); + }); + + describe('invalid data', () => { + test('user with email does not exist', async () => { + const newPassword = faker.internet.password(); + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + await expect( + UserService.resetPassword(userData.email, mockCode, newPassword), + ).rejects.toThrow(new ApiError(errors.INVALID_EMAIL)); + }); + + test('code does not verify with hash', async () => { + prismaMock.hash.findUnique.mockResolvedValueOnce( + generateHashData({ + hash: 'wrong hash', + }), + ); + + await expect( + UserService.resetPassword( + userData.email, + mockCode, + userData.password, + ), + ).rejects.toThrow(new ApiError(errors.INVALID_CODE)); + }); + + test('code is expired', async () => { + prismaMock.hash.findUnique.mockResolvedValueOnce( + generateHashData({ + hash: hashData.hash, + expiresAt: startOfYesterday(), + }), + ); + + await expect( + UserService.resetPassword( + userData.email, + mockCode, + userData.password, + ), + ).rejects.toThrow(new ApiError(errors.CODE_EXPIRED)); + }); + }); + }); }); diff --git a/src/tests/utils/generateData.ts b/src/tests/utils/generateData.ts index 1106825..8bc9e02 100644 --- a/src/tests/utils/generateData.ts +++ b/src/tests/utils/generateData.ts @@ -1,10 +1,25 @@ import { faker } from '@faker-js/faker'; -export const generateUserData = () => ({ +import { TypeHash, User, Hash } from '@prisma/client'; +import { endOfTomorrow } from 'date-fns'; + +export const generateUserData = (opts?: Partial) => ({ id: faker.string.uuid(), email: faker.internet.email(), name: faker.person.fullName(), password: faker.internet.password(), createdAt: faker.date.anytime(), updatedAt: faker.date.anytime(), + ...opts, +}); + +export const generateHashData = (opts?: Partial) => ({ + id: faker.string.uuid(), + createdAt: faker.date.anytime(), + updatedAt: faker.date.anytime(), + expiresAt: endOfTomorrow(), + type: TypeHash.RESET_PASSWORD, + userId: faker.string.uuid(), + hash: faker.string.alphanumeric(), + ...opts, }); From 919fdc320f8855789a1c4d566ebe1f9549eba3a0 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Mon, 4 Dec 2023 14:54:32 -0300 Subject: [PATCH 05/10] Rebase with main --- src/services/user.test.ts | 135 +++++++++++++++++++++----------------- src/services/user.ts | 8 ++- src/types/emails.ts | 1 + worker/worker.ts | 8 ++- 4 files changed, 85 insertions(+), 67 deletions(-) diff --git a/src/services/user.test.ts b/src/services/user.test.ts index 32a8190..04324c4 100644 --- a/src/services/user.test.ts +++ b/src/services/user.test.ts @@ -1,24 +1,25 @@ +import { startOfYesterday } from 'date-fns'; +import * as bcrypt from 'bcryptjs'; +import { faker } from '@faker-js/faker'; + +import prisma from 'root/prisma/client'; import { generateHashData, generateUserData } from 'tests/utils/generateData'; import { UserService } from 'services/user'; import { sendUserWithoutPassword } from 'utils/user'; import { addToMailQueue } from 'queue/queue'; import { EmailTypes } from 'types'; -import prisma from 'root/prisma/client'; import { ApiError } from 'utils/apiError'; import { errors } from 'config/errors'; -import * as bcrypt from 'bcryptjs'; - -import { prismaMock } from 'tests/prismaSetup'; -import { sendResetPasswordCode } from 'emails'; -import { faker } from '@faker-js/faker'; -import { startOfYesterday } from 'date-fns'; +import { verifyHash, generateCodeAndHash } from 'utils/hash'; jest.mock('utils/user'); jest.mock('queue/queue'); +jest.mock('utils/hash'); const mockMailQueueAdd = addToMailQueue as jest.Mock; const mockSendUserWithoutPassword = sendUserWithoutPassword as jest.Mock; -const mockSendResetPasswordCode = sendResetPasswordCode as jest.Mock; +const mockGenerateCodeAndHash = generateCodeAndHash as jest.Mock; +const mockVerifyHash = verifyHash as jest.Mock; const mockCode = String(Math.floor(100000 + Math.random() * 900000)); @@ -35,85 +36,84 @@ describe('User service: ', () => { afterEach(jest.clearAllMocks); - test('should create a new user with email', async () => { - const { password, ...userWithoutPassword } = userData; - mockSendUserWithoutPassword.mockResolvedValue(userWithoutPassword); - - await expect(UserService.create(userData)).resolves.toEqual( - userWithoutPassword, - ); - - expect(mockMailQueueAdd).toHaveBeenCalledWith('Sign up Email', { - emailType: EmailTypes.SIGN_UP, - email: userData.email, - }); - }); - - describe('When the user already exists', () => { - beforeEach(async () => { - await prisma.user.create({ - data: userData, - }); - }); - - test('should not create a new user', async () => { - const referenceError = new ApiError(errors.USER_ALREADY_EXISTS); - - await expect(UserService.create(userData)).rejects.toEqual( - referenceError, - ); - }); - }); - describe('create function', () => { test('should create a new user with email', async () => { - prismaMock.user.create.mockResolvedValue(userData); - prismaMock.user.update.mockResolvedValue(userData); const { password, ...userWithoutPassword } = userData; mockSendUserWithoutPassword.mockResolvedValue(userWithoutPassword); + await expect(UserService.create(userData)).resolves.toEqual( userWithoutPassword, ); + + expect(mockMailQueueAdd).toHaveBeenCalledWith('Sign up Email', { + emailType: EmailTypes.SIGN_UP, + email: userData.email, + }); }); - test('should not create a new user', async () => { - const referenceError = new Error('something went wrong'); + describe('When the user already exists', () => { + beforeEach(async () => { + await prisma.user.create({ + data: userData, + }); + }); + + test('should not create a new user', async () => { + const referenceError = new ApiError(errors.USER_ALREADY_EXISTS); - prismaMock.user.create.mockRejectedValue(referenceError); - await expect(UserService.create(userData)).rejects.toEqual( - referenceError, - ); + await expect(UserService.create(userData)).rejects.toEqual( + referenceError, + ); + }); }); }); describe('Request reset password code', () => { + beforeEach(async () => { + mockGenerateCodeAndHash.mockResolvedValue({ + code: mockCode, + hash: hashData.hash, + }); + }); + test('should create and return new hash', async () => { - prismaMock.user.findUnique.mockResolvedValue(userData); - prismaMock.hash.upsert.mockResolvedValue(hashData); - mockSendResetPasswordCode.mockResolvedValue(undefined); + await prisma.user.create({ + data: userData, + }); + await expect( UserService.requestResetPasswordCode(userData.email), ).resolves.toEqual(undefined); + + expect(mockMailQueueAdd).toHaveBeenCalledWith('Reset password code', { + emailType: EmailTypes.RESET_PASSWORD_CODE, + email: userData.email, + code: mockCode, + }); }); describe('invalid data', () => { test('user with email does not exist', async () => { - prismaMock.user.findUnique.mockResolvedValueOnce(null); await expect( UserService.requestResetPasswordCode(userData.email), ).rejects.toThrow(new ApiError(errors.INVALID_EMAIL)); + + expect(mockMailQueueAdd).toHaveBeenCalledTimes(0); }); }); }); describe('Reset Password', () => { - beforeEach(() => { - prismaMock.user.findUnique.mockResolvedValue(userData); - prismaMock.hash.findUnique.mockResolvedValue(hashData); - prismaMock.$transaction.mockResolvedValue(undefined); - }); + mockVerifyHash.mockResolvedValue(hashData); test('should update the password successfully', async () => { + await prisma.user.create({ + data: userData, + }); + await prisma.hash.create({ + data: hashData, + }); + await expect( UserService.resetPassword(userData.email, mockCode, userData.password), ).resolves.toEqual(undefined); @@ -122,7 +122,6 @@ describe('User service: ', () => { describe('invalid data', () => { test('user with email does not exist', async () => { const newPassword = faker.internet.password(); - prismaMock.user.findUnique.mockResolvedValueOnce(null); await expect( UserService.resetPassword(userData.email, mockCode, newPassword), @@ -130,11 +129,17 @@ describe('User service: ', () => { }); test('code does not verify with hash', async () => { - prismaMock.hash.findUnique.mockResolvedValueOnce( - generateHashData({ + await prisma.user.create({ + data: userData, + }); + await prisma.hash.create({ + data: generateHashData({ hash: 'wrong hash', + userId: userData.id, }), - ); + }); + + mockVerifyHash.mockRejectedValueOnce(new ApiError(errors.INVALID_CODE)); await expect( UserService.resetPassword( @@ -146,12 +151,18 @@ describe('User service: ', () => { }); test('code is expired', async () => { - prismaMock.hash.findUnique.mockResolvedValueOnce( - generateHashData({ + await prisma.user.create({ + data: userData, + }); + await prisma.hash.create({ + data: generateHashData({ hash: hashData.hash, expiresAt: startOfYesterday(), + userId: userData.id, }), - ); + }); + + mockVerifyHash.mockRejectedValueOnce(new ApiError(errors.CODE_EXPIRED)); await expect( UserService.resetPassword( diff --git a/src/services/user.ts b/src/services/user.ts index 4572a93..6e57c5c 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -17,7 +17,7 @@ import { errors } from 'config/errors'; import { addToMailQueue } from 'queue/queue'; import { generateCodeAndHash, verifyHash } from 'utils/hash'; import { config } from 'config/config'; -import { sendResetPasswordCode } from 'emails'; +// import { sendResetPasswordCode } from 'emails'; export class UserService { static find = async (id: string): Promise => { @@ -134,7 +134,11 @@ export class UserService { }, }); - await sendResetPasswordCode(email, code); + addToMailQueue('Reset password code', { + emailType: EmailTypes.RESET_PASSWORD_CODE, + email, + code, + }); }; static resetPassword = async ( diff --git a/src/types/emails.ts b/src/types/emails.ts index 2770e88..50001ca 100644 --- a/src/types/emails.ts +++ b/src/types/emails.ts @@ -1,3 +1,4 @@ export enum EmailTypes { SIGN_UP = 'SIGN_UP', + RESET_PASSWORD_CODE = 'RESET_PASSWORD_CODE', } diff --git a/worker/worker.ts b/worker/worker.ts index 436c58d..b3f97c6 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1,7 +1,6 @@ import { Job, Worker } from 'bullmq'; -import { config } from 'config/config'; import { appLogger } from 'config/logger'; -import { sendSignUpEmail } from 'emails'; +import { sendResetPasswordCode, sendSignUpEmail } from 'emails'; import { EmailTypes, WorkerQueues } from 'types'; import { redisConnection as connection } from 'utils/redis'; @@ -9,7 +8,10 @@ const mailWorkerJobHandler = async (job: Job) => { appLogger.info(`Handling job: [${job.id}]`); switch (job.data.emailType) { case EmailTypes.SIGN_UP: - sendSignUpEmail(config.appName, job.data.email); + sendSignUpEmail(job.data.email); + break; + case EmailTypes.RESET_PASSWORD_CODE: + sendResetPasswordCode(job.data.email, job.data.code); break; default: } From 0d8821ae156fe50d11ec66e0a1371f6b863afac2 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Mon, 4 Dec 2023 14:57:04 -0300 Subject: [PATCH 06/10] fix ci --- .github/workflows/node.js.yml | 1 + .woodpecker/.backend-ci.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 74fa828..5cda82e 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -29,6 +29,7 @@ env: REDIS_PORT: '6379' REDIS_USERNAME: 'default' JOBS_RETENTION_HOURS: '24' + OTP_EXPIRATION_TIME: '15' jobs: build: diff --git a/.woodpecker/.backend-ci.yml b/.woodpecker/.backend-ci.yml index d6107f8..549dd36 100644 --- a/.woodpecker/.backend-ci.yml +++ b/.woodpecker/.backend-ci.yml @@ -21,6 +21,7 @@ x-common: &common - REDIS_PORT=6379 - REDIS_USERNAME=default - JOBS_RETENTION_HOURS=24 + - OTP_EXPIRATION_TIME=15 pipeline: setup: From 125e465822a79ef62814239560ca5a2e13fe9a6d Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Tue, 5 Dec 2023 14:18:01 -0300 Subject: [PATCH 07/10] Fix comments --- .env.example | 2 +- .github/workflows/node.js.yml | 2 +- .woodpecker/.backend-ci.yml | 2 +- package-lock.json | 27 ------------- package.json | 2 - .../migration.sql | 21 ---------- .../migration.sql | 21 ++++++++++ prisma/schema.prisma | 10 ++--- src/config/config.ts | 4 +- src/services/user.test.ts | 39 ++++++++++--------- src/services/user.ts | 22 +++++------ src/tests/utils/generateData.ts | 8 ++-- src/types/config.ts | 2 +- src/utils/hash.ts | 35 ++++++++++------- 14 files changed, 88 insertions(+), 109 deletions(-) delete mode 100644 prisma/migrations/20231128184544_add_hash_table/migration.sql create mode 100644 prisma/migrations/20231204190920_add_tokens_table/migration.sql diff --git a/.env.example b/.env.example index e019b93..1fa1d84 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,4 @@ REDIS_PORT=6379 REDIS_USERNAME='' JOBS_RETENTION_HOURS=24 -OTP_EXPIRATION_TIME=15 +OTP_EXPIRATION_MINUTES=15 diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 5cda82e..a6b1e4a 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -29,7 +29,7 @@ env: REDIS_PORT: '6379' REDIS_USERNAME: 'default' JOBS_RETENTION_HOURS: '24' - OTP_EXPIRATION_TIME: '15' + OTP_EXPIRATION_MINUTES: '15' jobs: build: diff --git a/.woodpecker/.backend-ci.yml b/.woodpecker/.backend-ci.yml index 549dd36..92842f3 100644 --- a/.woodpecker/.backend-ci.yml +++ b/.woodpecker/.backend-ci.yml @@ -21,7 +21,7 @@ x-common: &common - REDIS_PORT=6379 - REDIS_USERNAME=default - JOBS_RETENTION_HOURS=24 - - OTP_EXPIRATION_TIME=15 + - OTP_EXPIRATION_MINUTES=15 pipeline: setup: diff --git a/package-lock.json b/package-lock.json index afe9c66..23f0e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "node-cron": "^3.0.2", "nodemailer": "^6.9.4", "nodemon": "^3.0.1", - "otp-generator": "^4.0.1", "prisma": "^5.5.2", "pug": "^3.0.2", "swagger-ui-express": "^5.0.0", @@ -54,7 +53,6 @@ "@types/node": "^18.17.1", "@types/node-cron": "^3.0.8", "@types/nodemailer": "^6.4.9", - "@types/otp-generator": "^4.0.2", "@types/pug": "^2.0.6", "@types/swagger-ui-express": "^4.1.3", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -1732,12 +1730,6 @@ "@types/node": "*" } }, - "node_modules/@types/otp-generator": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/otp-generator/-/otp-generator-4.0.2.tgz", - "integrity": "sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==", - "dev": true - }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -6699,14 +6691,6 @@ "node": ">=0.10.0" } }, - "node_modules/otp-generator": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/otp-generator/-/otp-generator-4.0.1.tgz", - "integrity": "sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==", - "engines": { - "node": ">=14.10.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9933,12 +9917,6 @@ "@types/node": "*" } }, - "@types/otp-generator": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/otp-generator/-/otp-generator-4.0.2.tgz", - "integrity": "sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==", - "dev": true - }, "@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -13544,11 +13522,6 @@ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" }, - "otp-generator": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/otp-generator/-/otp-generator-4.0.1.tgz", - "integrity": "sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==" - }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index 69123b7..f042dfc 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "@types/node": "^18.17.1", "@types/node-cron": "^3.0.8", "@types/nodemailer": "^6.4.9", - "@types/otp-generator": "^4.0.2", "@types/pug": "^2.0.6", "@types/swagger-ui-express": "^4.1.3", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -87,7 +86,6 @@ "node-cron": "^3.0.2", "nodemailer": "^6.9.4", "nodemon": "^3.0.1", - "otp-generator": "^4.0.1", "prisma": "^5.5.2", "pug": "^3.0.2", "swagger-ui-express": "^5.0.0", diff --git a/prisma/migrations/20231128184544_add_hash_table/migration.sql b/prisma/migrations/20231128184544_add_hash_table/migration.sql deleted file mode 100644 index 21fdae0..0000000 --- a/prisma/migrations/20231128184544_add_hash_table/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- CreateEnum -CREATE TYPE "TypeHash" AS ENUM ('RESET_PASSWORD'); - --- CreateTable -CREATE TABLE "Hash" ( - "id" TEXT NOT NULL, - "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "hash" TEXT NOT NULL, - "type" "TypeHash" NOT NULL, - "expires_at" TIMESTAMP(3) NOT NULL, - "user_id" TEXT, - - CONSTRAINT "Hash_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Hash_user_id_type_key" ON "Hash"("user_id", "type"); - --- AddForeignKey -ALTER TABLE "Hash" ADD CONSTRAINT "Hash_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20231204190920_add_tokens_table/migration.sql b/prisma/migrations/20231204190920_add_tokens_table/migration.sql new file mode 100644 index 0000000..02f109f --- /dev/null +++ b/prisma/migrations/20231204190920_add_tokens_table/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "TypeToken" AS ENUM ('RESET_PASSWORD'); + +-- CreateTable +CREATE TABLE "Tokens" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "token" TEXT NOT NULL, + "type" "TypeToken" NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "user_id" TEXT, + + CONSTRAINT "Tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tokens_user_id_type_key" ON "Tokens"("user_id", "type"); + +-- AddForeignKey +ALTER TABLE "Tokens" ADD CONSTRAINT "Tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b57d2db..1b1ab6f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,7 +18,7 @@ model User { email String @unique password String name String? - hash Hash[] + token Tokens[] } model Session { @@ -30,12 +30,12 @@ model Session { refreshToken String @unique } -model Hash { +model Tokens { id String @id @default(uuid()) createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamp(6) - hash String - type TypeHash + token String + type TypeToken expiresAt DateTime @map("expires_at") userId String? @map("user_id") user User? @relation(fields: [userId], references: [id]) @@ -43,6 +43,6 @@ model Hash { @@unique([userId, type]) } -enum TypeHash { +enum TypeToken { RESET_PASSWORD } diff --git a/src/config/config.ts b/src/config/config.ts index d91c836..2bc0aab 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -47,7 +47,7 @@ const envVarsSchema = z 'JOBS RETENTION HOURS must be a number', ), REDIS_USERNAME: z.string(), - OTP_EXPIRATION_TIME: z + OTP_EXPIRATION_MINUTES: z .string() .transform((val) => Number(val)) .refine( @@ -83,5 +83,5 @@ export const config: Config = { redisPort: envVars.REDIS_PORT, redisUsername: envVars.REDIS_USERNAME, jobsRetentionHours: envVars.JOBS_RETENTION_HOURS, - otpExpirationTime: envVars.OTP_EXPIRATION_TIME, + otpExpirationMinutes: envVars.OTP_EXPIRATION_MINUTES, }; diff --git a/src/services/user.test.ts b/src/services/user.test.ts index 04324c4..8220357 100644 --- a/src/services/user.test.ts +++ b/src/services/user.test.ts @@ -1,16 +1,15 @@ import { startOfYesterday } from 'date-fns'; -import * as bcrypt from 'bcryptjs'; import { faker } from '@faker-js/faker'; import prisma from 'root/prisma/client'; -import { generateHashData, generateUserData } from 'tests/utils/generateData'; +import { generateTokenData, generateUserData } from 'tests/utils/generateData'; import { UserService } from 'services/user'; import { sendUserWithoutPassword } from 'utils/user'; import { addToMailQueue } from 'queue/queue'; import { EmailTypes } from 'types'; import { ApiError } from 'utils/apiError'; import { errors } from 'config/errors'; -import { verifyHash, generateCodeAndHash } from 'utils/hash'; +import { verifyToken, generateCodeAndHash } from 'utils/hash'; jest.mock('utils/user'); jest.mock('queue/queue'); @@ -19,14 +18,14 @@ jest.mock('utils/hash'); const mockMailQueueAdd = addToMailQueue as jest.Mock; const mockSendUserWithoutPassword = sendUserWithoutPassword as jest.Mock; const mockGenerateCodeAndHash = generateCodeAndHash as jest.Mock; -const mockVerifyHash = verifyHash as jest.Mock; +const mockVerifyToken = verifyToken as jest.Mock; const mockCode = String(Math.floor(100000 + Math.random() * 900000)); const userData = generateUserData(); -const hashData = generateHashData({ +const tokenData = generateTokenData({ userId: userData.id, - hash: bcrypt.hashSync(mockCode, 8), + token: mockCode, }); describe('User service: ', () => { @@ -72,7 +71,7 @@ describe('User service: ', () => { beforeEach(async () => { mockGenerateCodeAndHash.mockResolvedValue({ code: mockCode, - hash: hashData.hash, + hash: tokenData.token, }); }); @@ -104,14 +103,14 @@ describe('User service: ', () => { }); describe('Reset Password', () => { - mockVerifyHash.mockResolvedValue(hashData); + mockVerifyToken.mockResolvedValue(tokenData); test('should update the password successfully', async () => { await prisma.user.create({ data: userData, }); - await prisma.hash.create({ - data: hashData, + await prisma.tokens.create({ + data: tokenData, }); await expect( @@ -132,14 +131,16 @@ describe('User service: ', () => { await prisma.user.create({ data: userData, }); - await prisma.hash.create({ - data: generateHashData({ - hash: 'wrong hash', + await prisma.tokens.create({ + data: generateTokenData({ + token: 'wrong token', userId: userData.id, }), }); - mockVerifyHash.mockRejectedValueOnce(new ApiError(errors.INVALID_CODE)); + mockVerifyToken.mockRejectedValueOnce( + new ApiError(errors.INVALID_CODE), + ); await expect( UserService.resetPassword( @@ -154,15 +155,17 @@ describe('User service: ', () => { await prisma.user.create({ data: userData, }); - await prisma.hash.create({ - data: generateHashData({ - hash: hashData.hash, + await prisma.tokens.create({ + data: generateTokenData({ + token: tokenData.token, expiresAt: startOfYesterday(), userId: userData.id, }), }); - mockVerifyHash.mockRejectedValueOnce(new ApiError(errors.CODE_EXPIRED)); + mockVerifyToken.mockRejectedValueOnce( + new ApiError(errors.CODE_EXPIRED), + ); await expect( UserService.resetPassword( diff --git a/src/services/user.ts b/src/services/user.ts index 6e57c5c..f35ae65 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -1,7 +1,7 @@ import * as bcrypt from 'bcryptjs'; import { addMinutes } from 'date-fns'; -import { Prisma, TypeHash } from '@prisma/client'; +import { Prisma, TypeToken } from '@prisma/client'; import prisma from 'root/prisma/client'; import { ApiError } from 'utils/apiError'; import { @@ -15,9 +15,8 @@ import { sendUserWithoutPassword } from 'utils/user'; import { emailRegex } from 'utils/constants'; import { errors } from 'config/errors'; import { addToMailQueue } from 'queue/queue'; -import { generateCodeAndHash, verifyHash } from 'utils/hash'; +import { generateCodeAndHash, verifyToken } from 'utils/hash'; import { config } from 'config/config'; -// import { sendResetPasswordCode } from 'emails'; export class UserService { static find = async (id: string): Promise => { @@ -112,24 +111,25 @@ export class UserService { if (!user) throw new ApiError(errors.INVALID_EMAIL); const { code, hash } = await generateCodeAndHash(); - const expirationDate = addMinutes(new Date(), config.otpExpirationTime); - await prisma.hash.upsert({ + const expirationDate = addMinutes(new Date(), config.otpExpirationMinutes); + + await prisma.tokens.upsert({ create: { userId: user.id, - hash, + token: hash, expiresAt: expirationDate, - type: TypeHash.RESET_PASSWORD, + type: TypeToken.RESET_PASSWORD, }, update: { - hash, + token: hash, expiresAt: expirationDate, userId: user.id, }, where: { userId_type: { userId: user.id, - type: TypeHash.RESET_PASSWORD, + type: TypeToken.RESET_PASSWORD, }, }, }); @@ -153,11 +153,11 @@ export class UserService { }); if (!user) throw new ApiError(errors.INVALID_EMAIL); - const hash = await verifyHash(user.id, TypeHash.RESET_PASSWORD, code); + const token = await verifyToken(user.id, TypeToken.RESET_PASSWORD, code); const hashedNewPassword = await bcrypt.hash(newPassword, 8); await prisma.$transaction([ - prisma.hash.delete({ where: { id: hash.id } }), + prisma.tokens.delete({ where: { id: token.id } }), prisma.user.update({ where: { id: user.id }, data: { password: hashedNewPassword }, diff --git a/src/tests/utils/generateData.ts b/src/tests/utils/generateData.ts index 8bc9e02..933af8c 100644 --- a/src/tests/utils/generateData.ts +++ b/src/tests/utils/generateData.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -import { TypeHash, User, Hash } from '@prisma/client'; +import { User, Tokens, TypeToken } from '@prisma/client'; import { endOfTomorrow } from 'date-fns'; export const generateUserData = (opts?: Partial) => ({ @@ -13,13 +13,13 @@ export const generateUserData = (opts?: Partial) => ({ ...opts, }); -export const generateHashData = (opts?: Partial) => ({ +export const generateTokenData = (opts?: Partial) => ({ id: faker.string.uuid(), createdAt: faker.date.anytime(), updatedAt: faker.date.anytime(), expiresAt: endOfTomorrow(), - type: TypeHash.RESET_PASSWORD, + type: TypeToken.RESET_PASSWORD, userId: faker.string.uuid(), - hash: faker.string.alphanumeric(), + token: faker.string.alphanumeric(), ...opts, }); diff --git a/src/types/config.ts b/src/types/config.ts index 25e068e..0ebc196 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -18,7 +18,7 @@ export interface Config { redisPort: number; redisUsername: string; jobsRetentionHours: number; - otpExpirationTime: number; + otpExpirationMinutes: number; } export interface ErrorInterface { diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 42ca67b..86aa6be 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -1,41 +1,46 @@ import * as bcrypt from 'bcryptjs'; -import { generate } from 'otp-generator'; -import { TypeHash } from '@prisma/client'; +import { TypeToken } from '@prisma/client'; import { errors } from 'config/errors'; import prisma from 'root/prisma/client'; import { ApiError } from './apiError'; -export const generateCodeAndHash = async () => { - const code = generate(6, { - specialChars: false, - lowerCaseAlphabets: false, - upperCaseAlphabets: false, - }); +const generateCode = (length: number) => { + const digits = '0123456789'; + let OTP = ''; + + for (let i = 0; i < length; i += 1) { + OTP += digits[Math.floor(Math.random() * 10)]; + } + return OTP; +}; + +export const generateCodeAndHash = async () => { + const code = generateCode(6); const hash = await bcrypt.hash(code, 8); return { code, hash }; }; -export const verifyHash = async ( +export const verifyToken = async ( userId: string, - hashType: TypeHash, + tokenType: TypeToken, code: string, ) => { - const hash = await prisma.hash.findUnique({ + const token = await prisma.tokens.findUnique({ where: { - userId_type: { userId, type: hashType }, + userId_type: { userId, type: tokenType }, }, }); - if (!hash || !bcrypt.compareSync(code, hash.hash)) { + if (!token || !bcrypt.compareSync(code, token.token)) { throw new ApiError(errors.INVALID_CODE); } - if (hash.expiresAt < new Date()) { + if (token.expiresAt < new Date()) { throw new ApiError(errors.CODE_EXPIRED); } - return hash; + return token; }; From 9c948dab7ee65d4748976eecaa8664d063976f58 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Wed, 6 Dec 2023 10:54:10 -0300 Subject: [PATCH 08/10] Fix comments --- src/services/user.test.ts | 4 ++-- src/services/user.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/user.test.ts b/src/services/user.test.ts index 8220357..6c9d120 100644 --- a/src/services/user.test.ts +++ b/src/services/user.test.ts @@ -95,7 +95,7 @@ describe('User service: ', () => { test('user with email does not exist', async () => { await expect( UserService.requestResetPasswordCode(userData.email), - ).rejects.toThrow(new ApiError(errors.INVALID_EMAIL)); + ).resolves.toEqual(undefined); expect(mockMailQueueAdd).toHaveBeenCalledTimes(0); }); @@ -124,7 +124,7 @@ describe('User service: ', () => { await expect( UserService.resetPassword(userData.email, mockCode, newPassword), - ).rejects.toThrow(new ApiError(errors.INVALID_EMAIL)); + ).resolves.toEqual(undefined); }); test('code does not verify with hash', async () => { diff --git a/src/services/user.ts b/src/services/user.ts index f35ae65..fcf0b01 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -108,7 +108,7 @@ export class UserService { email, }, }); - if (!user) throw new ApiError(errors.INVALID_EMAIL); + if (!user) return; const { code, hash } = await generateCodeAndHash(); @@ -151,7 +151,7 @@ export class UserService { email, }, }); - if (!user) throw new ApiError(errors.INVALID_EMAIL); + if (!user) return; const token = await verifyToken(user.id, TypeToken.RESET_PASSWORD, code); const hashedNewPassword = await bcrypt.hash(newPassword, 8); From 3dd4a5491166fa88346cf98c26b83fb59feb57e8 Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Thu, 7 Dec 2023 14:03:03 -0300 Subject: [PATCH 09/10] Add test --- src/services/user.test.ts | 59 +++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/src/services/user.test.ts b/src/services/user.test.ts index 6c9d120..c21bc96 100644 --- a/src/services/user.test.ts +++ b/src/services/user.test.ts @@ -1,3 +1,4 @@ +import * as bcrypt from 'bcryptjs'; import { startOfYesterday } from 'date-fns'; import { faker } from '@faker-js/faker'; @@ -5,38 +6,31 @@ import prisma from 'root/prisma/client'; import { generateTokenData, generateUserData } from 'tests/utils/generateData'; import { UserService } from 'services/user'; import { sendUserWithoutPassword } from 'utils/user'; -import { addToMailQueue } from 'queue/queue'; +import * as queues from 'queue/queue'; import { EmailTypes } from 'types'; import { ApiError } from 'utils/apiError'; import { errors } from 'config/errors'; -import { verifyToken, generateCodeAndHash } from 'utils/hash'; jest.mock('utils/user'); jest.mock('queue/queue'); -jest.mock('utils/hash'); -const mockMailQueueAdd = addToMailQueue as jest.Mock; const mockSendUserWithoutPassword = sendUserWithoutPassword as jest.Mock; -const mockGenerateCodeAndHash = generateCodeAndHash as jest.Mock; -const mockVerifyToken = verifyToken as jest.Mock; const mockCode = String(Math.floor(100000 + Math.random() * 900000)); const userData = generateUserData(); + const tokenData = generateTokenData({ userId: userData.id, - token: mockCode, + token: bcrypt.hashSync(mockCode, 8), }); describe('User service: ', () => { - beforeEach(() => { - mockMailQueueAdd.mockResolvedValue(undefined); - }); - afterEach(jest.clearAllMocks); describe('create function', () => { test('should create a new user with email', async () => { + const spyAddToMailQueue = jest.spyOn(queues, 'addToMailQueue'); const { password, ...userWithoutPassword } = userData; mockSendUserWithoutPassword.mockResolvedValue(userWithoutPassword); @@ -44,7 +38,7 @@ describe('User service: ', () => { userWithoutPassword, ); - expect(mockMailQueueAdd).toHaveBeenCalledWith('Sign up Email', { + expect(spyAddToMailQueue).toHaveBeenCalledWith('Sign up Email', { emailType: EmailTypes.SIGN_UP, email: userData.email, }); @@ -68,14 +62,12 @@ describe('User service: ', () => { }); describe('Request reset password code', () => { - beforeEach(async () => { - mockGenerateCodeAndHash.mockResolvedValue({ - code: mockCode, - hash: tokenData.token, - }); + afterEach(() => { + jest.restoreAllMocks(); }); test('should create and return new hash', async () => { + const spyAddToMailQueue = jest.spyOn(queues, 'addToMailQueue'); await prisma.user.create({ data: userData, }); @@ -84,27 +76,22 @@ describe('User service: ', () => { UserService.requestResetPasswordCode(userData.email), ).resolves.toEqual(undefined); - expect(mockMailQueueAdd).toHaveBeenCalledWith('Reset password code', { - emailType: EmailTypes.RESET_PASSWORD_CODE, - email: userData.email, - code: mockCode, - }); + expect(spyAddToMailQueue).toHaveBeenCalledTimes(1); }); describe('invalid data', () => { test('user with email does not exist', async () => { + const spyAddToMailQueue = jest.spyOn(queues, 'addToMailQueue'); await expect( UserService.requestResetPasswordCode(userData.email), ).resolves.toEqual(undefined); - expect(mockMailQueueAdd).toHaveBeenCalledTimes(0); + expect(spyAddToMailQueue).toHaveBeenCalledTimes(0); }); }); }); describe('Reset Password', () => { - mockVerifyToken.mockResolvedValue(tokenData); - test('should update the password successfully', async () => { await prisma.user.create({ data: userData, @@ -127,6 +114,20 @@ describe('User service: ', () => { ).resolves.toEqual(undefined); }); + test('token does not exist', async () => { + await prisma.user.create({ + data: userData, + }); + + await expect( + UserService.resetPassword( + userData.email, + mockCode, + userData.password, + ), + ).rejects.toThrow(new ApiError(errors.INVALID_CODE)); + }); + test('code does not verify with hash', async () => { await prisma.user.create({ data: userData, @@ -138,10 +139,6 @@ describe('User service: ', () => { }), }); - mockVerifyToken.mockRejectedValueOnce( - new ApiError(errors.INVALID_CODE), - ); - await expect( UserService.resetPassword( userData.email, @@ -163,10 +160,6 @@ describe('User service: ', () => { }), }); - mockVerifyToken.mockRejectedValueOnce( - new ApiError(errors.CODE_EXPIRED), - ); - await expect( UserService.resetPassword( userData.email, From 8ee7a4fb0455cc55a4ab5420803dc38f16f8c30d Mon Sep 17 00:00:00 2001 From: Gabriel Machin Date: Thu, 11 Jan 2024 17:14:27 -0300 Subject: [PATCH 10/10] Fix comment --- src/emails/resetPasswordCodeTemplate.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emails/resetPasswordCodeTemplate.pug b/src/emails/resetPasswordCodeTemplate.pug index 21e79aa..459a722 100644 --- a/src/emails/resetPasswordCodeTemplate.pug +++ b/src/emails/resetPasswordCodeTemplate.pug @@ -3,4 +3,4 @@ html title #{appName} password reset code body h1 Hello #{username}, - p To recover your password use the next code #{code} + p To recover your password use the next code #{code}.