From b6e1b2ff62ee5d24e7c450dccd0520e0c371eb4d Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Mon, 27 Nov 2023 17:36:46 +0100 Subject: [PATCH] :sparkles: TotpCodes in tables instead of in customer --- src/app/api/customer/AuthController.ts | 9 +- .../20231127154201_totp_table/migration.sql | 30 +++++ src/common/databases/schema.prisma | 22 +++- .../repositories/CustomersRepository.ts | 5 +- .../repositories/TotpCodesRepository.ts | 61 +++++++++ .../CustomersService/CustomersService.ts | 124 +++++++++++------- 6 files changed, 192 insertions(+), 59 deletions(-) create mode 100644 src/common/databases/migrations/20231127154201_totp_table/migration.sql create mode 100644 src/common/repositories/TotpCodesRepository.ts diff --git a/src/app/api/customer/AuthController.ts b/src/app/api/customer/AuthController.ts index 40b07421..f125cd2f 100644 --- a/src/app/api/customer/AuthController.ts +++ b/src/app/api/customer/AuthController.ts @@ -159,12 +159,15 @@ export default class AuthController extends ApiController { } try { - const customer = await this.customerService.verifyTotpCode(totpCode, email); - if (!customer) { + const code = await this.customerService.verifyTotpCode(totpCode, email); + if (!code) { this.httpNotFoundRequest(response, "Customer not found"); return; } - this.httpSuccess(response, { validCode: true, firstConnection: customer.password === null }); + this.httpSuccess(response, { + validCode: true, + reason: code.reason, + }); } catch (error) { if (error instanceof InvalidTotpCodeError || error instanceof TotpCodeExpiredError) { this.httpUnauthorized(response, error.message); diff --git a/src/common/databases/migrations/20231127154201_totp_table/migration.sql b/src/common/databases/migrations/20231127154201_totp_table/migration.sql new file mode 100644 index 00000000..89a9d24a --- /dev/null +++ b/src/common/databases/migrations/20231127154201_totp_table/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `totpCode` on the `customers` table. All the data in the column will be lost. + - You are about to drop the column `totpCodeExpire` on the `customers` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "TotpCodesReasons" AS ENUM ('LOGIN', 'RESET_PASSWORD', 'FIRST_LOGIN'); + +-- AlterTable +ALTER TABLE "customers" DROP COLUMN "totpCode", +DROP COLUMN "totpCodeExpire"; + +-- CreateTable +CREATE TABLE "totp_codes" ( + "uid" TEXT NOT NULL, + "customer_uid" VARCHAR(255) NOT NULL, + "code" VARCHAR(255) NOT NULL, + "reason" "TotpCodesReasons" NOT NULL DEFAULT 'LOGIN', + "expire_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "totp_codes_pkey" PRIMARY KEY ("uid") +); + +-- CreateIndex +CREATE UNIQUE INDEX "totp_codes_uid_key" ON "totp_codes"("uid"); + +-- AddForeignKey +ALTER TABLE "totp_codes" ADD CONSTRAINT "totp_codes_customer_uid_fkey" FOREIGN KEY ("customer_uid") REFERENCES "customers"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/common/databases/schema.prisma b/src/common/databases/schema.prisma index 1f77bd0d..3c9c0831 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -102,10 +102,7 @@ model Customers { office_folders OfficeFolders[] @relation("OfficeFolderHasCustomers") documents Documents[] password String? @db.VarChar(255) - totpCode String? @db.VarChar(255) - totpCodeExpire DateTime? @default(now()) - - + totpCodes TotpCodes[] @@map("customers") } @@ -345,6 +342,23 @@ model Votes { @@map("votes") } +model TotpCodes { + uid String @id @unique @default(uuid()) + customer Customers @relation(fields: [customer_uid], references: [uid], onDelete: Cascade) + customer_uid String @db.VarChar(255) + code String @db.VarChar(255) + reason TotpCodesReasons @default(LOGIN) + expire_at DateTime? @default(now()) + + @@map("totp_codes") +} + +enum TotpCodesReasons { + LOGIN + RESET_PASSWORD + FIRST_LOGIN +} + enum ECivility { MALE FEMALE diff --git a/src/common/repositories/CustomersRepository.ts b/src/common/repositories/CustomersRepository.ts index 6844fddb..65876c6f 100644 --- a/src/common/repositories/CustomersRepository.ts +++ b/src/common/repositories/CustomersRepository.ts @@ -5,7 +5,6 @@ import { Customers, ECivility, ECustomerStatus, Prisma } from "@prisma/client"; import { Customer } from "le-coffre-resources/dist/SuperAdmin"; type IExcludedCustomerVars = { - totpCode?: string | null; totpCodeExpire?: Date | null; password?: string; }; @@ -36,7 +35,7 @@ export default class CustomersRepository extends BaseRepository { public async findOne(query: Prisma.CustomersFindFirstArgs) { query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows); if (!query.include) return this.model.findFirst({ ...query, include: { contact: true } }); - return this.model.findFirst({ ...query, include: { contact: { include: { address: true } } } }); + return this.model.findFirst({ ...query, include: { contact: { include: { address: true } }, ...query.include } }); } /** @@ -93,8 +92,6 @@ export default class CustomersRepository extends BaseRepository { address: {}, }, }, - totpCode: excludedVars && excludedVars.totpCode, - totpCodeExpire: excludedVars && excludedVars.totpCodeExpire, password: excludedVars && excludedVars.password, }, }; diff --git a/src/common/repositories/TotpCodesRepository.ts b/src/common/repositories/TotpCodesRepository.ts new file mode 100644 index 00000000..f70f55ce --- /dev/null +++ b/src/common/repositories/TotpCodesRepository.ts @@ -0,0 +1,61 @@ +import Database from "@Common/databases/database"; +import BaseRepository from "@Repositories/BaseRepository"; +import { Service } from "typedi"; +import { Prisma, TotpCodes } from "@prisma/client"; +import { TotpCodes as TotpCode } from "le-coffre-resources/dist/Customer"; + +type IExcludedTotpCodesVars = { + code?: string; + expire_at?: Date; +}; + +@Service() +export default class TotpCodesRepository extends BaseRepository { + constructor(private database: Database) { + super(); + } + protected get model() { + return this.database.getClient().totpCodes; + } + protected get instanceDb() { + return this.database.getClient(); + } + + /** + * @description : Find many totp codes + */ + public async findMany(query: Prisma.TotpCodesFindManyArgs) { + query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows); + if (!query.include) return this.model.findMany({ ...query }); + return this.model.findMany({ ...query }); + } + + /** + * @description : Find one totp code + */ + public async findOne(query: Prisma.TotpCodesFindFirstArgs) { + query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows); + if (!query.include) return this.model.findFirst({ ...query }); + return this.model.findFirst({ ...query }); + } + + /** + * @description : Create a customer + */ + public async create(totpCode: TotpCode, excludedVars: IExcludedTotpCodesVars): Promise { + const createArgs: Prisma.TotpCodesCreateArgs = { + data: { + code: excludedVars.code!, + reason: totpCode.reason!, + customer: { + connect: { + uid: totpCode.customer_uid!, + }, + }, + expire_at: excludedVars.expire_at!, + }, + }; + + return this.model.create({ ...createArgs }); + } +} diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index ccab039e..496967e1 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -1,7 +1,9 @@ import { Customers, Prisma } from "@prisma/client"; import CustomersRepository from "@Repositories/CustomersRepository"; +import TotpCodesRepository from "@Repositories/TotpCodesRepository"; import BaseService from "@Services/BaseService"; import AuthService from "@Services/common/AuthService/AuthService"; +import TotpCodes, { TotpCodesReasons } from "le-coffre-resources/dist/Customer/TotpCodes"; import { Customer } from "le-coffre-resources/dist/Notary"; import { Service } from "typedi"; @@ -42,7 +44,11 @@ export class PasswordAlreadySetError extends Error { } @Service() export default class CustomersService extends BaseService { - constructor(private customerRepository: CustomersRepository, private authService: AuthService) { + constructor( + private customerRepository: CustomersRepository, + private authService: AuthService, + private totpCodesRepository: TotpCodesRepository, + ) { super(); } @@ -65,23 +71,33 @@ export default class CustomersService extends BaseService { /** * @description : Send SMS to verify the email of a customer (2FA) * 1: Check if the customer exists - * 2: Check if the SMS code is still valid + * 2: Check in the array of totpCodes if one is still valid * 3: Generate a new SMS code * 4: Save the SMS code in database * 5: Send the SMS code to the customer */ public async verifyEmail2FASms(email: string): Promise { + // 1: Check if the customer exists const customer = await this.getByEmail(email); if (!customer) return null; const now = new Date().getTime(); - // Check if the SMS code is still valid - if (customer.totpCodeExpire && now < customer.totpCodeExpire.getTime()) throw new SmsNotExpiredError(); + const customerHydrated = Customer.hydrate(customer); + + // 2: Check in the array of totpCodes if one is still valid + const validTotpCode = customerHydrated.totpCodes?.find((totpCode) => { + return totpCode.expire_at && totpCode.expire_at.getTime() > now; + }); + if (validTotpCode) throw new SmsNotExpiredError(); + + // 3: Generate a new SMS code const totpPin = this.generateTotp(); - await this.saveTotpPin(customer, totpPin, new Date(now + 5 * 60000)); + const reason = customer.password ? TotpCodesReasons.LOGIN : TotpCodesReasons.FIRST_LOGIN; + // 4: Save the SMS code in database + await this.saveTotpPin(customer, totpPin, new Date(now + 5 * 60000), reason); - // Send the SMS code to the customer + // 5: Send the SMS code to the customer await this.sendSmsCodeToCustomer(totpPin, customer); return customer; } @@ -90,11 +106,10 @@ export default class CustomersService extends BaseService { * @description : Set the password of a customer when it's the first time they connect * 1: Check if the customer exists * 2: Check if the password is already set - * 3: Check if the SMS code is existing and is not expired + * 3: Check if a totp code is existing and is not expired in the array * 4: Check if the SMS code is valid * 5: Hash the password - * 6: Set the password in database - * 7: Returns the customer + * 6: Set the password in database and return the result of the update * @param email * @param totpCode * @param password @@ -108,28 +123,28 @@ export default class CustomersService extends BaseService { // 2: Check if the password is already set if (customer.password) throw new PasswordAlreadySetError(); - // 3: Check if the SMS code is existing and is not expired - if (!customer.totpCode || !customer.totpCodeExpire || new Date().getTime() > customer.totpCodeExpire.getTime()) - throw new TotpCodeExpiredError(); + const customerHydrated = Customer.hydrate(customer); + // 3: Check if a totp code is existing and is not expired in the array + const validTotpCode = customerHydrated.totpCodes?.find((totpCode) => { + return totpCode.expire_at && new Date().getTime() < totpCode.expire_at.getTime(); + }); + if (!validTotpCode) throw new TotpCodeExpiredError(); // 4: Check if the SMS code is valid - if (customer.totpCode !== totpCode) throw new InvalidTotpCodeError(); + if (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError(); // 5: Hash the password const hashedPassword = await this.authService.hashPassword(password); - // 6: Set the password in database - await this.setPassword(customer, hashedPassword); - - // 7: Returns the customer - return customer; + // 6: Set the password in database and return the result of the update + return await this.setPassword(customer, hashedPassword); } /** * * @description : Login a customer * 1: Check if the customer exists - * 2: Check if the SMS code is existing and is not expired + * 2: Check if a totp code is existing and is not expired in the array * 3: Check if the SMS code is valid * 4: Check if the user has a password or it's their first login * 5: Check if the password is valid @@ -143,13 +158,16 @@ export default class CustomersService extends BaseService { // 1: Check if the customer exists const customer = await this.getByEmail(email); if (!customer) return null; + const customerHydrated = Customer.hydrate(customer); - // 2: Check if the SMS code is existing and is not expired - if (!customer.totpCode || !customer.totpCodeExpire || new Date().getTime() > customer.totpCodeExpire.getTime()) - throw new TotpCodeExpiredError(); + // 2: Check if a totp code is existing and is not expired in the array + const validTotpCode = customerHydrated.totpCodes?.find((totpCode) => { + return totpCode.expire_at && new Date().getTime() < totpCode.expire_at.getTime(); + }); + if (!validTotpCode) throw new TotpCodeExpiredError(); // 3: Check if the SMS code is valid - if (customer.totpCode !== totpCode) throw new InvalidTotpCodeError(); + if (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError(); // 4: Check if the user has a password or it's their first login if (!customer.password) throw new NotRegisteredCustomerError(); @@ -158,18 +176,12 @@ export default class CustomersService extends BaseService { const isPasswordValid = await this.authService.comparePassword(password, customer.password); if (!isPasswordValid) throw new InvalidPasswordError(); - await this.customerRepository.update( + return await this.customerRepository.update( customer.uid as string, Customer.hydrate({ ...customer, }), - { - totpCode: null, - totpCodeExpire: null, - }, ); - // 6: Return the customer - return customer; } /** @@ -183,8 +195,6 @@ export default class CustomersService extends BaseService { ...customer, }), { - totpCode: null, - totpCodeExpire: null, password, }, ); @@ -199,6 +209,7 @@ export default class CustomersService extends BaseService { }, include: { contact: true, + totpCodes: true, }, }); } @@ -206,16 +217,24 @@ export default class CustomersService extends BaseService { /** * @description : Saves a TotpPin in database */ - private async saveTotpPin(customer: Customer, totpPin: number, expireAt: Date) { + private async saveTotpPin(customer: Customer, totpPin: number, expireAt: Date, reason: TotpCodesReasons) { + // Create the totpCode in table using repository + await this.totpCodesRepository.create( + TotpCodes.hydrate({ + reason, + customer_uid: customer.uid, + customer: Customer.hydrate(customer), + }), + { + code: totpPin.toString(), + expire_at: expireAt, + }, + ); return await this.customerRepository.update( customer.uid as string, Customer.hydrate({ ...customer, }), - { - totpCode: totpPin.toString(), - totpCodeExpire: expireAt, - }, ); } @@ -227,24 +246,33 @@ export default class CustomersService extends BaseService { console.log(totpPin); } - public async verifyTotpCode(totpCode: string, email: string): Promise { - // 1: Check if the customer exists - // 2: Check if the SMS code is existing and is not expired - // 3: Check if the SMS code is valid - // 4: Return the customer - + /** + * + * 1: Check if the customer exists + * 2: Check if a totp code is existing and is not expired in the array + * 3: Check if the totp code is valid + * 4: Return the customer + * @param totpCode + * @param email + * @returns + */ + public async verifyTotpCode(totpCode: string, email: string): Promise { // 1: Check if the customer exists const customer = await this.getByEmail(email); if (!customer) return null; - // 2: Check if the SMS code is existing and is not expired - if (!customer.totpCode || !customer.totpCodeExpire || new Date().getTime() > customer.totpCodeExpire.getTime()) - throw new TotpCodeExpiredError(); + const customerHydrated = Customer.hydrate(customer); + + // 2: Check if a totp code is existing and is not expired in the array + const validTotpCode = customerHydrated.totpCodes?.find((totpCode) => { + return totpCode.expire_at && new Date().getTime() < totpCode.expire_at.getTime(); + }); + if (!validTotpCode) throw new TotpCodeExpiredError(); // 3: Check if the SMS code is valid - if (customer.totpCode !== totpCode) throw new InvalidTotpCodeError(); + if (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError(); // 4: Return the customer - return customer; + return validTotpCode; } }