From bbf480fbda896cdfb53d9a9284e2953ab20f4980 Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Wed, 29 Nov 2023 14:38:15 +0100 Subject: [PATCH 1/5] :sparkles: Disable the totp code when used --- package.json | 2 +- .../repositories/TotpCodesRepository.ts | 16 +++++++++++++++- .../CustomersService/CustomersService.ts | 19 ++++++++++++++----- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 645340ef..7c3fdd93 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "file-type-checker": "^1.0.8", "fp-ts": "^2.16.1", "jsonwebtoken": "^9.0.0", - "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.98", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.99", "module-alias": "^2.2.2", "monocle-ts": "^2.3.13", "multer": "^1.4.5-lts.1", diff --git a/src/common/repositories/TotpCodesRepository.ts b/src/common/repositories/TotpCodesRepository.ts index f70f55ce..6e84eba0 100644 --- a/src/common/repositories/TotpCodesRepository.ts +++ b/src/common/repositories/TotpCodesRepository.ts @@ -40,7 +40,7 @@ export default class TotpCodesRepository extends BaseRepository { } /** - * @description : Create a customer + * @description : Create a totp code */ public async create(totpCode: TotpCode, excludedVars: IExcludedTotpCodesVars): Promise { const createArgs: Prisma.TotpCodesCreateArgs = { @@ -58,4 +58,18 @@ export default class TotpCodesRepository extends BaseRepository { return this.model.create({ ...createArgs }); } + + /** + * Disable a totp code + */ + public async disable(totpCode: TotpCode): Promise { + return this.model.update({ + where: { + uid: totpCode.uid!, + }, + data: { + expire_at: new Date(), + }, + }); + } } diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 496967e1..062687f6 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -108,8 +108,9 @@ export default class CustomersService extends BaseService { * 2: Check if the password is already set * 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 and return the result of the update + * 5: Disable the totp code used + * 6: Hash the password + * 7: Set the password in database and return the result of the update * @param email * @param totpCode * @param password @@ -133,10 +134,13 @@ export default class CustomersService extends BaseService { // 4: Check if the SMS code is valid if (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError(); - // 5: Hash the password + // 5: Disable the totp code used + await this.totpCodesRepository.disable(validTotpCode); + + // 6: Hash the password const hashedPassword = await this.authService.hashPassword(password); - // 6: Set the password in database and return the result of the update + // 7: Set the password in database and return the result of the update return await this.setPassword(customer, hashedPassword); } @@ -148,7 +152,8 @@ export default class CustomersService extends BaseService { * 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 - * 6: Return the customer + * 6: Disable the totp code used + * 7: Return the customer * @param email * @param totpCode * @param password @@ -176,6 +181,10 @@ export default class CustomersService extends BaseService { const isPasswordValid = await this.authService.comparePassword(password, customer.password); if (!isPasswordValid) throw new InvalidPasswordError(); + // 6: Disable the totp code used + await this.totpCodesRepository.disable(validTotpCode); + + // 7: Return the customer return await this.customerRepository.update( customer.uid as string, Customer.hydrate({ From 82d24a71a447b92b5169be0f3123b420ad67d30c Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Wed, 29 Nov 2023 14:44:41 +0100 Subject: [PATCH 2/5] :sparkles: Verify codes reasons and add a route for password forgotten --- src/app/api/customer/AuthController.ts | 25 ++++++++ .../CustomersService/CustomersService.ts | 58 ++++++++++++++++--- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/app/api/customer/AuthController.ts b/src/app/api/customer/AuthController.ts index f125cd2f..218748d7 100644 --- a/src/app/api/customer/AuthController.ts +++ b/src/app/api/customer/AuthController.ts @@ -45,6 +45,31 @@ export default class AuthController extends ApiController { } } + @Post("/api/v1/customer/auth/ask-new-password") + protected async askNewPassword(req: Request, response: Response) { + const email = req.body["email"]; + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + try { + const customer = await this.customerService.generateCodeForNewPassword(email); + if (!customer) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + this.httpSuccess(response, { partialPhoneNumber: customer.contact?.cell_phone_number.replace(/\s/g, "").slice(-4) }); + } catch (error) { + if (error instanceof SmsNotExpiredError) { + this.httpTooEarlyRequest(response, error.message); + return; + } + console.log(error); + this.httpInternalError(response); + } + } + @Post("/api/v1/customer/auth/login") protected async login(req: Request, response: Response) { const email = req.body["email"]; diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 062687f6..65bd52f8 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -102,12 +102,45 @@ export default class CustomersService extends BaseService { return customer; } + /** + * @description : Send SMS to verify the email of a customer (2FA) + * 1: Check if the customer exists + * 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 generateCodeForNewPassword(email: string): Promise { + // 1: Check if the customer exists + const customer = await this.getByEmail(email); + if (!customer) return null; + const now = new Date().getTime(); + + 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(); + + // 4: Save the SMS code in database + await this.saveTotpPin(customer, totpPin, new Date(now + 5 * 60000), TotpCodesReasons.RESET_PASSWORD); + + // 5: Send the SMS code to the customer + await this.sendSmsCodeToCustomer(totpPin, customer); + return customer; + } + /** * @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 a totp code is existing and is not expired in the array - * 4: Check if the SMS code is valid + * 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 the totpcode reason is valid * 5: Disable the totp code used * 6: Hash the password * 7: Set the password in database and return the result of the update @@ -121,19 +154,26 @@ export default class CustomersService extends BaseService { const customer = await this.getByEmail(email); if (!customer) return null; - // 2: Check if the password is already set - if (customer.password) throw new PasswordAlreadySetError(); - const customerHydrated = Customer.hydrate(customer); - // 3: Check if a totp code is existing and is not expired in the array + // 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(); - // 4: Check if the SMS code is valid + // 3: Check if the SMS code is valid if (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError(); + // 4: Check the totpcode reason is valid + // If the customer already has a password, the reason must be RESET_PASSWORD + // If the customer doesn't have a password, the reason must be FIRST_LOGIN + if ( + (customer.password && validTotpCode.reason !== TotpCodesReasons.RESET_PASSWORD) || + (!customer.password && validTotpCode.reason !== TotpCodesReasons.FIRST_LOGIN) + ) + throw new InvalidTotpCodeError(); + // 5: Disable the totp code used await this.totpCodesRepository.disable(validTotpCode); @@ -167,7 +207,7 @@ export default class CustomersService extends BaseService { // 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(); + return totpCode.expire_at && new Date().getTime() < totpCode.expire_at.getTime() && totpCode.reason === TotpCodesReasons.LOGIN; }); if (!validTotpCode) throw new TotpCodeExpiredError(); From 8ef401b4b20e5d3fb87d5182f57cf411a8535415 Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Wed, 29 Nov 2023 16:04:58 +0100 Subject: [PATCH 3/5] :sparkles: Password forgotten working --- .../customer/CustomersService/CustomersService.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 65bd52f8..1f7b144c 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -120,10 +120,23 @@ export default class CustomersService extends BaseService { // 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; + return totpCode.expire_at && totpCode.expire_at.getTime() > now && totpCode.reason === TotpCodesReasons.RESET_PASSWORD; }); if (validTotpCode) throw new SmsNotExpiredError(); + // 3: Archive all active totp codes for this customer + const activeTotpCodes = customerHydrated.totpCodes?.filter((totpCode) => { + return totpCode.expire_at && totpCode.expire_at.getTime() > now; + }); + + if (activeTotpCodes) { + await Promise.all( + activeTotpCodes.map(async (totpCode) => { + await this.totpCodesRepository.disable(totpCode); + }), + ); + } + // 3: Generate a new SMS code const totpPin = this.generateTotp(); From 9dc1049ce717a06cda7804e53018396f2e1aa3f0 Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Wed, 29 Nov 2023 16:47:50 +0100 Subject: [PATCH 4/5] :sparkles: Send another code working --- src/app/api/customer/AuthController.ts | 25 +++++++++++++ .../20231127154201_totp_table/migration.sql | 3 +- src/common/databases/schema.prisma | 13 +++---- .../repositories/TotpCodesRepository.ts | 11 ++---- .../CustomersService/CustomersService.ts | 36 +++++++++++++++++-- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/app/api/customer/AuthController.ts b/src/app/api/customer/AuthController.ts index 218748d7..7b2b1dd0 100644 --- a/src/app/api/customer/AuthController.ts +++ b/src/app/api/customer/AuthController.ts @@ -202,4 +202,29 @@ export default class AuthController extends ApiController { this.httpInternalError(response); } } + + @Post("/api/v1/customer/auth/send-another-code") + protected async sendAnotherCode(req: Request, response: Response) { + const email = req.body["email"]; + if (!email) { + this.httpBadRequest(response, "email is required"); + return; + } + + try { + const customer = await this.customerService.askAnotherCode(email); + if (!customer) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + this.httpSuccess(response, { partialPhoneNumber: customer.contact?.cell_phone_number.replace(/\s/g, "").slice(-4) }); + } catch (error) { + if (error instanceof InvalidTotpCodeError || error instanceof TotpCodeExpiredError) { + this.httpUnauthorized(response, error.message); + return; + } + console.log(error); + this.httpInternalError(response); + } + } } diff --git a/src/common/databases/migrations/20231127154201_totp_table/migration.sql b/src/common/databases/migrations/20231127154201_totp_table/migration.sql index 89a9d24a..d511b72a 100644 --- a/src/common/databases/migrations/20231127154201_totp_table/migration.sql +++ b/src/common/databases/migrations/20231127154201_totp_table/migration.sql @@ -19,7 +19,8 @@ CREATE TABLE "totp_codes" ( "code" VARCHAR(255) NOT NULL, "reason" "TotpCodesReasons" NOT NULL DEFAULT 'LOGIN', "expire_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, - + "created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3), CONSTRAINT "totp_codes_pkey" PRIMARY KEY ("uid") ); diff --git a/src/common/databases/schema.prisma b/src/common/databases/schema.prisma index 3c9c0831..be5c663b 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -343,13 +343,14 @@ model 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) + 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()) - + expire_at DateTime? @default(now()) + created_at DateTime? @default(now()) + updated_at DateTime? @updatedAt @@map("totp_codes") } diff --git a/src/common/repositories/TotpCodesRepository.ts b/src/common/repositories/TotpCodesRepository.ts index 6e84eba0..8d9d9c33 100644 --- a/src/common/repositories/TotpCodesRepository.ts +++ b/src/common/repositories/TotpCodesRepository.ts @@ -4,11 +4,6 @@ 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) { @@ -42,17 +37,17 @@ export default class TotpCodesRepository extends BaseRepository { /** * @description : Create a totp code */ - public async create(totpCode: TotpCode, excludedVars: IExcludedTotpCodesVars): Promise { + public async create(totpCode: TotpCode): Promise { const createArgs: Prisma.TotpCodesCreateArgs = { data: { - code: excludedVars.code!, + code: totpCode.code!, reason: totpCode.reason!, customer: { connect: { uid: totpCode.customer_uid!, }, }, - expire_at: excludedVars.expire_at!, + expire_at: totpCode.expire_at!, }, }; diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 1f7b144c..96aeb5ae 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -246,6 +246,37 @@ export default class CustomersService extends BaseService { ); } + public async askAnotherCode(email: string): Promise { + // 1: Check if the customer exists + const customer = await this.getByEmail(email); + if (!customer) return null; + const now = new Date().getTime(); + + const customerHydrated = Customer.hydrate(customer); + + // 2: Get last code sent + const lastCode = customerHydrated.totpCodes?.find((totpCode) => { + return totpCode.expire_at && totpCode.expire_at.getTime() > now; + }); + if (!lastCode) throw new SmsNotExpiredError(); + + // 3: Check if it was created more than 30 seconds ago + if (lastCode.created_at && lastCode.created_at.getTime() > now - 30000) throw new SmsNotExpiredError(); + + // 4: Generate a new SMS code + const totpPin = this.generateTotp(); + + // 5: Disable the old code + await this.totpCodesRepository.disable(lastCode); + + // 6: Save the SMS code in database + await this.saveTotpPin(customer, totpPin, new Date(now + 5 * 60000), lastCode.reason!); + + // 7: Send the SMS code to the customer + await this.sendSmsCodeToCustomer(totpPin, customer); + return customer; + } + /** * @description : Set password for a customer * @throws {Error} If customer cannot be updated @@ -286,11 +317,10 @@ export default class CustomersService extends BaseService { reason, customer_uid: customer.uid, customer: Customer.hydrate(customer), - }), - { + created_at: new Date(), code: totpPin.toString(), expire_at: expireAt, - }, + }), ); return await this.customerRepository.update( customer.uid as string, From b931e98c29e78075d579477e642461f478a17915 Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Wed, 29 Nov 2023 16:52:24 +0100 Subject: [PATCH 5/5] :sparkles: Send the right error when asking for a new code --- src/app/api/customer/AuthController.ts | 3 ++- .../customer/CustomersService/CustomersService.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/api/customer/AuthController.ts b/src/app/api/customer/AuthController.ts index 7b2b1dd0..631dce4c 100644 --- a/src/app/api/customer/AuthController.ts +++ b/src/app/api/customer/AuthController.ts @@ -8,6 +8,7 @@ import CustomersService, { NotRegisteredCustomerError, PasswordAlreadySetError, SmsNotExpiredError, + TooSoonForNewCode, TotpCodeExpiredError, } from "@Services/customer/CustomersService/CustomersService"; import AuthService from "@Services/common/AuthService/AuthService"; @@ -219,7 +220,7 @@ export default class AuthController extends ApiController { } this.httpSuccess(response, { partialPhoneNumber: customer.contact?.cell_phone_number.replace(/\s/g, "").slice(-4) }); } catch (error) { - if (error instanceof InvalidTotpCodeError || error instanceof TotpCodeExpiredError) { + if (error instanceof TooSoonForNewCode || error instanceof TotpCodeExpiredError) { this.httpUnauthorized(response, error.message); return; } diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 96aeb5ae..e4dc47a9 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -42,6 +42,12 @@ export class PasswordAlreadySetError extends Error { super("Password already set"); } } + +export class TooSoonForNewCode extends Error { + constructor() { + super("You need to wait at least 30 seconds before asking for a new code"); + } +} @Service() export default class CustomersService extends BaseService { constructor( @@ -258,10 +264,10 @@ export default class CustomersService extends BaseService { const lastCode = customerHydrated.totpCodes?.find((totpCode) => { return totpCode.expire_at && totpCode.expire_at.getTime() > now; }); - if (!lastCode) throw new SmsNotExpiredError(); + if (!lastCode) throw new TotpCodeExpiredError(); // 3: Check if it was created more than 30 seconds ago - if (lastCode.created_at && lastCode.created_at.getTime() > now - 30000) throw new SmsNotExpiredError(); + if (lastCode.created_at && lastCode.created_at.getTime() > now - 30000) throw new TooSoonForNewCode(); // 4: Generate a new SMS code const totpPin = this.generateTotp();