TotpCodes in tables instead of in customer

This commit is contained in:
Maxime Lalo 2023-11-27 17:36:46 +01:00
parent cdbb3e3257
commit b6e1b2ff62
6 changed files with 192 additions and 59 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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

View File

@ -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,
},
};

View File

@ -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<TotpCodes> {
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 });
}
}

View File

@ -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<Customer | null> {
// 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>(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>(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>(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>({
...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<TotpCodes>({
reason,
customer_uid: customer.uid,
customer: Customer.hydrate<Customer>(customer),
}),
{
code: totpPin.toString(),
expire_at: expireAt,
},
);
return await this.customerRepository.update(
customer.uid as string,
Customer.hydrate<Customer>({
...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<Customer | null> {
// 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<TotpCodes | null> {
// 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>(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;
}
}