Merge branch 'dev' into sendSms

This commit is contained in:
Vins 2023-11-30 10:15:11 +01:00
commit 20478738ca
6 changed files with 194 additions and 34 deletions

View File

@ -56,7 +56,7 @@
"file-type-checker": "^1.0.8", "file-type-checker": "^1.0.8",
"fp-ts": "^2.16.1", "fp-ts": "^2.16.1",
"jsonwebtoken": "^9.0.0", "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", "module-alias": "^2.2.2",
"monocle-ts": "^2.3.13", "monocle-ts": "^2.3.13",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",

View File

@ -8,6 +8,7 @@ import CustomersService, {
NotRegisteredCustomerError, NotRegisteredCustomerError,
PasswordAlreadySetError, PasswordAlreadySetError,
SmsNotExpiredError, SmsNotExpiredError,
TooSoonForNewCode,
TotpCodeExpiredError, TotpCodeExpiredError,
} from "@Services/customer/CustomersService/CustomersService"; } from "@Services/customer/CustomersService/CustomersService";
import AuthService from "@Services/common/AuthService/AuthService"; import AuthService from "@Services/common/AuthService/AuthService";
@ -45,6 +46,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") @Post("/api/v1/customer/auth/login")
protected async login(req: Request, response: Response) { protected async login(req: Request, response: Response) {
const email = req.body["email"]; const email = req.body["email"];
@ -177,4 +203,29 @@ export default class AuthController extends ApiController {
this.httpInternalError(response); 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 TooSoonForNewCode || error instanceof TotpCodeExpiredError) {
this.httpUnauthorized(response, error.message);
return;
}
console.log(error);
this.httpInternalError(response);
}
}
} }

View File

@ -19,7 +19,8 @@ CREATE TABLE "totp_codes" (
"code" VARCHAR(255) NOT NULL, "code" VARCHAR(255) NOT NULL,
"reason" "TotpCodesReasons" NOT NULL DEFAULT 'LOGIN', "reason" "TotpCodesReasons" NOT NULL DEFAULT 'LOGIN',
"expire_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, "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") CONSTRAINT "totp_codes_pkey" PRIMARY KEY ("uid")
); );

View File

@ -349,7 +349,8 @@ model TotpCodes {
code String @db.VarChar(255) code String @db.VarChar(255)
reason TotpCodesReasons @default(LOGIN) 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") @@map("totp_codes")
} }

View File

@ -4,11 +4,6 @@ import { Service } from "typedi";
import { Prisma, TotpCodes } from "@prisma/client"; import { Prisma, TotpCodes } from "@prisma/client";
import { TotpCodes as TotpCode } from "le-coffre-resources/dist/Customer"; import { TotpCodes as TotpCode } from "le-coffre-resources/dist/Customer";
type IExcludedTotpCodesVars = {
code?: string;
expire_at?: Date;
};
@Service() @Service()
export default class TotpCodesRepository extends BaseRepository { export default class TotpCodesRepository extends BaseRepository {
constructor(private database: Database) { constructor(private database: Database) {
@ -40,22 +35,36 @@ export default class TotpCodesRepository extends BaseRepository {
} }
/** /**
* @description : Create a customer * @description : Create a totp code
*/ */
public async create(totpCode: TotpCode, excludedVars: IExcludedTotpCodesVars): Promise<TotpCodes> { public async create(totpCode: TotpCode): Promise<TotpCodes> {
const createArgs: Prisma.TotpCodesCreateArgs = { const createArgs: Prisma.TotpCodesCreateArgs = {
data: { data: {
code: excludedVars.code!, code: totpCode.code!,
reason: totpCode.reason!, reason: totpCode.reason!,
customer: { customer: {
connect: { connect: {
uid: totpCode.customer_uid!, uid: totpCode.customer_uid!,
}, },
}, },
expire_at: excludedVars.expire_at!, expire_at: totpCode.expire_at!,
}, },
}; };
return this.model.create({ ...createArgs }); return this.model.create({ ...createArgs });
} }
/**
* Disable a totp code
*/
public async disable(totpCode: TotpCode): Promise<TotpCodes> {
return this.model.update({
where: {
uid: totpCode.uid!,
},
data: {
expire_at: new Date(),
},
});
}
} }

View File

@ -44,6 +44,12 @@ export class PasswordAlreadySetError extends Error {
super("Password already set"); 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() @Service()
export default class CustomersService extends BaseService { export default class CustomersService extends BaseService {
constructor( constructor(
@ -106,14 +112,61 @@ export default class CustomersService extends BaseService {
return customer; 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<Customer | null> {
// 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>(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 && 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();
// 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 * @description : Set the password of a customer when it's the first time they connect
* 1: Check if the customer exists * 1: Check if the customer exists
* 2: Check if the password is already set * 2: Check if a totp code is existing and is not expired in the array
* 3: 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 SMS code is valid * 4: Check the totpcode reason is valid
* 5: Hash the password * 5: Disable the totp code used
* 6: Set the password in database and return the result of the update * 6: Hash the password
* 7: Set the password in database and return the result of the update
* @param email * @param email
* @param totpCode * @param totpCode
* @param password * @param password
@ -124,23 +177,33 @@ export default class CustomersService extends BaseService {
const customer = await this.getByEmail(email); const customer = await this.getByEmail(email);
if (!customer) return null; if (!customer) return null;
// 2: Check if the password is already set
if (customer.password) throw new PasswordAlreadySetError();
const customerHydrated = Customer.hydrate<Customer>(customer); const customerHydrated = Customer.hydrate<Customer>(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) => { 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();
}); });
if (!validTotpCode) throw new TotpCodeExpiredError(); 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(); if (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError();
// 5: Hash the password // 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);
// 6: Hash the password
const hashedPassword = await this.authService.hashPassword(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); return await this.setPassword(customer, hashedPassword);
} }
@ -152,7 +215,8 @@ export default class CustomersService extends BaseService {
* 3: Check if the SMS code is valid * 3: Check if the SMS code is valid
* 4: Check if the user has a password or it's their first login * 4: Check if the user has a password or it's their first login
* 5: Check if the password is valid * 5: Check if the password is valid
* 6: Return the customer * 6: Disable the totp code used
* 7: Return the customer
* @param email * @param email
* @param totpCode * @param totpCode
* @param password * @param password
@ -166,7 +230,7 @@ export default class CustomersService extends BaseService {
// 2: 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) => { 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(); if (!validTotpCode) throw new TotpCodeExpiredError();
@ -180,6 +244,10 @@ export default class CustomersService extends BaseService {
const isPasswordValid = await this.authService.comparePassword(password, customer.password); const isPasswordValid = await this.authService.comparePassword(password, customer.password);
if (!isPasswordValid) throw new InvalidPasswordError(); 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( return await this.customerRepository.update(
customer.uid as string, customer.uid as string,
Customer.hydrate<Customer>({ Customer.hydrate<Customer>({
@ -188,6 +256,37 @@ export default class CustomersService extends BaseService {
); );
} }
public async askAnotherCode(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();
const customerHydrated = Customer.hydrate<Customer>(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 TotpCodeExpiredError();
// 3: Check if it was created more than 30 seconds ago
if (lastCode.created_at && lastCode.created_at.getTime() > now - 30000) throw new TooSoonForNewCode();
// 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 * @description : Set password for a customer
* @throws {Error} If customer cannot be updated * @throws {Error} If customer cannot be updated
@ -228,11 +327,10 @@ export default class CustomersService extends BaseService {
reason, reason,
customer_uid: customer.uid, customer_uid: customer.uid,
customer: Customer.hydrate<Customer>(customer), customer: Customer.hydrate<Customer>(customer),
}), created_at: new Date(),
{
code: totpPin.toString(), code: totpPin.toString(),
expire_at: expireAt, expire_at: expireAt,
}, }),
); );
return await this.customerRepository.update( return await this.customerRepository.update(
customer.uid as string, customer.uid as string,