diff --git a/package.json b/package.json index 6b50b10e..9689e3ff 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@pinata/sdk": "^2.1.0", "@prisma/client": "^4.11.0", "adm-zip": "^0.5.10", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "classnames": "^2.3.2", @@ -55,13 +56,14 @@ "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.94", + "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", "next": "^13.1.5", "node-cache": "^5.1.2", "node-schedule": "^2.1.1", + "ovh": "^2.0.3", "prisma-query": "^2.0.0", "puppeteer": "^21.3.4", "reflect-metadata": "^0.1.13", @@ -74,6 +76,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.3", + "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.13", "@types/cron": "^2.0.1", "@types/express": "^4.17.16", diff --git a/src/app/api/customer/AuthController.ts b/src/app/api/customer/AuthController.ts new file mode 100644 index 00000000..631dce4c --- /dev/null +++ b/src/app/api/customer/AuthController.ts @@ -0,0 +1,231 @@ +import { Response, Request } from "express"; +import { Controller, Post } from "@ControllerPattern/index"; +import ApiController from "@Common/system/controller-pattern/ApiController"; +import { Service } from "typedi"; +import CustomersService, { + InvalidPasswordError, + InvalidTotpCodeError, + NotRegisteredCustomerError, + PasswordAlreadySetError, + SmsNotExpiredError, + TooSoonForNewCode, + TotpCodeExpiredError, +} from "@Services/customer/CustomersService/CustomersService"; +import AuthService from "@Services/common/AuthService/AuthService"; +import { Customer } from "le-coffre-resources/dist/SuperAdmin"; + +@Controller() +@Service() +export default class AuthController extends ApiController { + constructor(private customerService: CustomersService, private authService: AuthService) { + super(); + } + + @Post("/api/v1/customer/auth/mail/verify-sms") + protected async mailVerifySms(req: Request, response: Response) { + const email = req.body["email"]; + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + try { + const customer = await this.customerService.verifyEmail2FASms(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/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"]; + const totpCode = req.body["totpCode"]; + const password = req.body["password"]; + + if (!email) { + this.httpBadRequest(response, "email is required"); + return; + } + + if (!totpCode) { + this.httpBadRequest(response, "totpCode is required"); + return; + } + + if (!password) { + this.httpBadRequest(response, "password is required"); + return; + } + + try { + const customer = await this.customerService.login(email, totpCode, password); + if (!customer) { + this.httpBadRequest(response, "Customer not found"); + return; + } + const customerHydrated = Customer.hydrate(customer); + const payload = await this.authService.getCustomerJwtPayload([customerHydrated]); + const accessToken = this.authService.generateAccessToken(payload); + const refreshToken = this.authService.generateRefreshToken(payload); + this.httpSuccess(response, { accessToken, refreshToken }); + } catch (error) { + if (error instanceof TotpCodeExpiredError || error instanceof NotRegisteredCustomerError) { + this.httpBadRequest(response, error.message); + return; + } + + if (error instanceof InvalidTotpCodeError || error instanceof InvalidPasswordError) { + this.httpUnauthorized(response, error.message); + return; + } + + console.log(error); + this.httpInternalError(response); + return; + } + } + + @Post("/api/v1/customer/auth/set-password") + protected async setPassword(req: Request, response: Response) { + const email = req.body["email"]; + const totpCode = req.body["totpCode"]; + const password = req.body["password"]; + + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + if (!totpCode) { + this.httpBadRequest(response, "Sms code is required"); + return; + } + + if (!password) { + this.httpBadRequest(response, "Password is required"); + return; + } + + try { + const customer = await this.customerService.setFirstPassword(email, totpCode, password); + if (!customer) { + this.httpBadRequest(response, "Customer not found"); + return; + } + + const customerHydrated = Customer.hydrate(customer); + const payload = await this.authService.getCustomerJwtPayload([customerHydrated]); + const accessToken = this.authService.generateAccessToken(payload); + const refreshToken = this.authService.generateRefreshToken(payload); + this.httpSuccess(response, { accessToken, refreshToken }); + } catch (error) { + if (error instanceof TotpCodeExpiredError || error instanceof PasswordAlreadySetError) { + this.httpBadRequest(response, error.message); + return; + } + + if (error instanceof InvalidTotpCodeError) { + this.httpUnauthorized(response, error.message); + return; + } + + console.log(error); + this.httpInternalError(response); + return; + } + } + + @Post("/api/v1/customer/auth/verify-totp-code") + protected async verifyTotpCode(req: Request, response: Response) { + const totpCode = req.body["totpCode"]; + const email = req.body["email"]; + if (!totpCode) { + this.httpBadRequest(response, "totpCode is required"); + return; + } + + if (!email) { + this.httpBadRequest(response, "email is required"); + return; + } + + try { + const code = await this.customerService.verifyTotpCode(totpCode, email); + if (!code) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + this.httpSuccess(response, { + validCode: true, + reason: code.reason, + }); + } catch (error) { + if (error instanceof InvalidTotpCodeError || error instanceof TotpCodeExpiredError) { + this.httpUnauthorized(response, error.message); + return; + } + console.log(error); + 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); + } + } +} diff --git a/src/app/index.ts b/src/app/index.ts index f5c3f00a..2de03a00 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -45,7 +45,7 @@ import LiveVoteController from "./api/super-admin/LiveVoteController"; import DocumentControllerId360 from "./api/id360/DocumentController"; import CustomerControllerId360 from "./api/id360/CustomerController"; import UserNotificationController from "./api/notary/UserNotificationController"; - +import AuthController from "./api/customer/AuthController"; /** * @description This allow to declare all controllers used in the application @@ -99,5 +99,6 @@ export default { Container.get(UserNotificationController); Container.get(DocumentControllerId360); Container.get(CustomerControllerId360); + Container.get(AuthController); }, }; diff --git a/src/common/config/variables/Variables.ts b/src/common/config/variables/Variables.ts index 5a0d22dd..5f4638f1 100644 --- a/src/common/config/variables/Variables.ts +++ b/src/common/config/variables/Variables.ts @@ -109,6 +109,18 @@ export class BackendVariables { @IsNotEmpty() public readonly DOCAPOST_APP_PASSWORD!: string; + @IsNotEmpty() + public readonly SMS_PROVIDER!: string; + + @IsNotEmpty() + public readonly OVH_APP_KEY!: string; + + @IsNotEmpty() + public readonly OVH_APP_SECRET!: string; + + @IsNotEmpty() + public readonly OVH_CONSUMER_KEY!: string; + public constructor() { dotenv.config(); this.DATABASE_PORT = process.env["DATABASE_PORT"]!; @@ -146,6 +158,11 @@ export class BackendVariables { this.BACK_API_HOST = process.env["BACK_API_HOST"]!; this.DOCAPOST_APP_ID = process.env["DOCAPOST_APP_ID"]!; this.DOCAPOST_APP_PASSWORD = process.env["DOCAPOST_APP_PASSWORD"]!; + this.SMS_PROVIDER = process.env["SMS_PROVIDER"]!; + this.OVH_APP_KEY = process.env["OVH_APP_KEY"]!; + this.OVH_APP_SECRET = process.env["OVH_APP_SECRET"]!; + this.OVH_CONSUMER_KEY = process.env["OVH_CONSUMER_KEY"]!; + } public async validate(groups?: string[]) { diff --git a/src/common/databases/migrations/20231123104754_customer_login/migration.sql b/src/common/databases/migrations/20231123104754_customer_login/migration.sql new file mode 100644 index 00000000..db9e4228 --- /dev/null +++ b/src/common/databases/migrations/20231123104754_customer_login/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "password" VARCHAR(255), +ADD COLUMN "totpCode" VARCHAR(255), +ADD COLUMN "totpCodeExpire" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP; 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..d511b72a --- /dev/null +++ b/src/common/databases/migrations/20231127154201_totp_table/migration.sql @@ -0,0 +1,31 @@ +/* + 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, + "created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3), + 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 dcd5a5b0..be5c663b 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -101,7 +101,8 @@ model Customers { updated_at DateTime? @updatedAt office_folders OfficeFolders[] @relation("OfficeFolderHasCustomers") documents Documents[] - + password String? @db.VarChar(255) + totpCodes TotpCodes[] @@map("customers") } @@ -341,6 +342,24 @@ 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()) + created_at DateTime? @default(now()) + updated_at DateTime? @updatedAt + @@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 8a27293a..65876c6f 100644 --- a/src/common/repositories/CustomersRepository.ts +++ b/src/common/repositories/CustomersRepository.ts @@ -4,6 +4,10 @@ import { Service } from "typedi"; import { Customers, ECivility, ECustomerStatus, Prisma } from "@prisma/client"; import { Customer } from "le-coffre-resources/dist/SuperAdmin"; +type IExcludedCustomerVars = { + totpCodeExpire?: Date | null; + password?: string; +}; @Service() export default class CustomersRepository extends BaseRepository { constructor(private database: Database) { @@ -25,6 +29,15 @@ export default class CustomersRepository extends BaseRepository { return this.model.findMany({ ...query, include: { contact: { include: { address: true } } } }); } + /** + * @description : Find one customers + */ + 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 } }, ...query.include } }); + } + /** * @description : Create a customer */ @@ -61,7 +74,7 @@ export default class CustomersRepository extends BaseRepository { /** * @description : Update data from a customer */ - public async update(uid: string, customer: Customer): Promise { + public async update(uid: string, customer: Customer, excludedVars?: IExcludedCustomerVars): Promise { const updateArgs: Prisma.CustomersUpdateArgs = { where: { uid: uid, @@ -79,8 +92,10 @@ export default class CustomersRepository extends BaseRepository { address: {}, }, }, + password: excludedVars && excludedVars.password, }, }; + if (customer.contact!.address) { updateArgs.data.contact!.update!.address!.update = { address: customer.contact!.address!.address, @@ -88,6 +103,7 @@ export default class CustomersRepository extends BaseRepository { city: customer.contact!.address!.city, }; } + return this.model.update({ ...updateArgs, include: { contact: true } }); } diff --git a/src/common/repositories/TotpCodesRepository.ts b/src/common/repositories/TotpCodesRepository.ts new file mode 100644 index 00000000..8d9d9c33 --- /dev/null +++ b/src/common/repositories/TotpCodesRepository.ts @@ -0,0 +1,70 @@ +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"; + +@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 totp code + */ + public async create(totpCode: TotpCode): Promise { + const createArgs: Prisma.TotpCodesCreateArgs = { + data: { + code: totpCode.code!, + reason: totpCode.reason!, + customer: { + connect: { + uid: totpCode.customer_uid!, + }, + }, + expire_at: totpCode.expire_at!, + }, + }; + + 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/common/system/controller-pattern/BaseController.ts b/src/common/system/controller-pattern/BaseController.ts index 7baa9c02..0e5003cc 100644 --- a/src/common/system/controller-pattern/BaseController.ts +++ b/src/common/system/controller-pattern/BaseController.ts @@ -20,6 +20,10 @@ export default abstract class BaseController { return this.httpResponse(response, HttpCodes.BAD_REQUEST, responseData); } + protected httpTooEarlyRequest(response: Response, responseData: IResponseData = "Http Too Early Request") { + return this.httpResponse(response, HttpCodes.TOO_EARLY, responseData); + } + protected httpValidationError(response: Response, responseData: IResponseData = "Http Validation Error") { return this.httpResponse(response, HttpCodes.VALIDATION_ERROR, responseData); } diff --git a/src/common/system/controller-pattern/HttpCodes.ts b/src/common/system/controller-pattern/HttpCodes.ts index 95c4a67d..bd89e047 100644 --- a/src/common/system/controller-pattern/HttpCodes.ts +++ b/src/common/system/controller-pattern/HttpCodes.ts @@ -9,5 +9,6 @@ enum HttpCodes { NOT_FOUND = 404, UNAUTHORIZED = 401, FORBIDDEN = 403, + TOO_EARLY = 425, } export default HttpCodes; diff --git a/src/services/common/AuthService/AuthService.ts b/src/services/common/AuthService/AuthService.ts index f4f35b53..6be3ae25 100644 --- a/src/services/common/AuthService/AuthService.ts +++ b/src/services/common/AuthService/AuthService.ts @@ -6,6 +6,7 @@ import UsersService from "@Services/super-admin/UsersService/UsersService"; import CustomersService from "@Services/super-admin/CustomersService/CustomersService"; import { ECustomerStatus } from "@prisma/client"; import { Customer } from "le-coffre-resources/dist/Notary"; +import bcrypt from "bcrypt"; enum PROVIDER_OPENID { idNot = "idNot", @@ -19,9 +20,9 @@ export interface ICustomerJwtPayload { } export interface IdNotJwtPayload { - sub: string, - profile_idn: string, - entity_idn: string, + sub: string; + profile_idn: string; + entity_idn: string; } export interface IUserJwtPayload { @@ -44,7 +45,7 @@ export default class AuthService extends BaseService { } public async getCustomerJwtPayload(customers: Customer[]): Promise { - for (const customer of customers){ + for (const customer of customers) { if (customer.status === ECustomerStatus["PENDING"]) { customer.status = ECustomerStatus["VALIDATED"]; await this.customerService.update(customer.uid!, customer); @@ -52,7 +53,7 @@ export default class AuthService extends BaseService { } return { customerId: customers[0]!.uid!, - email: customers[0]!.contact!.email, + email: customers[0]!.contact!.email, }; } @@ -69,7 +70,7 @@ export default class AuthService extends BaseService { if (user.office_role) { user.office_role.rules.forEach((rule) => { - if(!rules.includes(rule.name)) { + if (!rules.includes(rule.name)) { rules.push(rule.name); } }); @@ -84,11 +85,11 @@ export default class AuthService extends BaseService { }; } public generateAccessToken(user: any): string { - return jwt.sign({ ...user}, this.variables.ACCESS_TOKEN_SECRET, { expiresIn: "15m" }); + return jwt.sign({ ...user }, this.variables.ACCESS_TOKEN_SECRET, { expiresIn: "15m" }); } public generateRefreshToken(user: any): string { - return jwt.sign({ ...user}, this.variables.REFRESH_TOKEN_SECRET, { expiresIn: "1h" }); + return jwt.sign({ ...user }, this.variables.REFRESH_TOKEN_SECRET, { expiresIn: "1h" }); } public verifyAccessToken(token: string, callback?: VerifyCallback) { @@ -98,4 +99,12 @@ export default class AuthService extends BaseService { public verifyRefreshToken(token: string, callback?: VerifyCallback) { return jwt.verify(token, this.variables.REFRESH_TOKEN_SECRET, callback); } + + public comparePassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } + + public hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); + } } diff --git a/src/services/common/OvhService/OvhService.ts b/src/services/common/OvhService/OvhService.ts new file mode 100644 index 00000000..b05b8e03 --- /dev/null +++ b/src/services/common/OvhService/OvhService.ts @@ -0,0 +1,43 @@ +import { BackendVariables } from "@Common/config/variables/Variables"; +import BaseService from "@Services/BaseService"; +import { Service } from "typedi"; + +Service() +export default class OvhService extends BaseService { + + constructor(private variables: BackendVariables) { + super(); + } + + /** + * @description : Get all Customers + * @throws {Error} If Customers cannot be get + */ + public async sendSms(phoneNumber: string, message: string): Promise { + + const ovh = require('ovh')({ + appKey: this.variables.OVH_APP_KEY, + appSecret: this.variables.OVH_APP_SECRET, + consumerKey: this.variables.OVH_CONSUMER_KEY, + }); + + // Get the serviceName (name of your SMS account) + ovh.request('GET', '/sms', function (err: any, serviceName: string) { + if(err) { + console.log(err, serviceName); + } + else { + console.log("My account SMS is " + serviceName); + + // Send a simple SMS with a short number using your serviceName + ovh.request('POST', '/sms/' + serviceName + '/jobs', { + message: message, + senderForResponse: true, + receivers: [phoneNumber] + }, function (errsend: any, result: any) { + console.log(errsend, result); + }); + } + }); + } +} \ No newline at end of file diff --git a/src/services/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 8ec5f672..5e0d0b47 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -1,11 +1,64 @@ +import { BackendVariables } from "@Common/config/variables/Variables"; 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"; +import OvhService from "@Services/common/OvhService/OvhService"; +export class SmsNotExpiredError extends Error { + constructor() { + super("SMS code not expired"); + } +} + +export class TotpCodeExpiredError extends Error { + constructor() { + super("Totp code not found or expired"); + } +} + +export class InvalidTotpCodeError extends Error { + constructor() { + super("Invalid Totp code"); + } +} + +export class NotRegisteredCustomerError extends Error { + constructor() { + super("Customer not registered"); + } +} + +export class InvalidPasswordError extends Error { + constructor() { + super("Invalid password"); + } +} + +export class PasswordAlreadySetError extends Error { + constructor() { + 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(private customerRepository: CustomersRepository) { + constructor( + private customerRepository: CustomersRepository, + private authService: AuthService, + private totpCodesRepository: TotpCodesRepository, + private variables: BackendVariables, + private ovhService: OvhService, + ) { super(); } @@ -16,4 +69,331 @@ export default class CustomersService extends BaseService { public async get(query: Prisma.CustomersFindManyArgs): Promise { return this.customerRepository.findMany(query); } -} \ No newline at end of file + + /** + * @description : Get all Customers + * @throws {Error} If Customers cannot be get + */ + public async getOne(query: Prisma.CustomersFindFirstArgs): Promise { + return this.customerRepository.findOne(query); + } + + /** + * @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 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(); + + 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(); + + 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); + + // 5: Send the SMS code to the customer + await this.sendSmsCodeToCustomer(totpPin, 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 { + // 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 && 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 + * 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 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 + * @param email + * @param totpCode + * @param password + * @returns + */ + public async setFirstPassword(email: string, totpCode: string, password: string): Promise { + // 1: Check if the customer exists + const customer = await this.getByEmail(email); + if (!customer) return null; + + 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 (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); + + // 6: Hash the password + const hashedPassword = await this.authService.hashPassword(password); + + // 7: 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 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 + * 6: Disable the totp code used + * 7: Return the customer + * @param email + * @param totpCode + * @param password + * @returns Customer | null + */ + public async login(email: string, totpCode: string, password: string): Promise { + // 1: Check if the customer exists + const customer = await this.getByEmail(email); + if (!customer) return null; + 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() && totpCode.reason === TotpCodesReasons.LOGIN; + }); + if (!validTotpCode) throw new TotpCodeExpiredError(); + + // 3: Check if the SMS code is valid + 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(); + + // 5: Check if the password is valid + 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({ + ...customer, + }), + ); + } + + 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 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 + * @throws {Error} If customer cannot be updated + */ + private async setPassword(customer: Customer, password: string) { + return await this.customerRepository.update( + customer.uid as string, + Customer.hydrate({ + ...customer, + }), + { + password, + }, + ); + } + + private getByEmail(email: string) { + return this.customerRepository.findOne({ + where: { + contact: { + email, + }, + }, + include: { + contact: true, + totpCodes: true, + }, + }); + } + + /** + * @description : Saves a TotpPin in database + */ + 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), + created_at: new Date(), + code: totpPin.toString(), + expire_at: expireAt, + }), + ); + return await this.customerRepository.update( + customer.uid as string, + Customer.hydrate({ + ...customer, + }), + ); + } + + private generateTotp() { + return Math.floor(100000 + Math.random() * 900000); + } + + private async sendSmsCodeToCustomer(totpPin: number, customer: Customer) { + try { + // Sélectionnez le fournisseur de SMS en fonction de la variable d'environnement + const selectedProvider = this.variables.SMS_PROVIDER === 'OVH' ? this.ovhService : this.ovhService; + + // Envoi du SMS + if(!customer.contact?.phone_number) return false; + let success = await selectedProvider.sendSms(customer.contact?.phone_number, totpPin.toString()); + + // Si l'envoi échoue, basculez automatiquement sur le second fournisseur + // if (!success) { + // const alternateProvider = this.variables.SMS_PROVIDER === 'OVH' ? this.smsService2 : this.ovhService; + // success = await alternateProvider.sendSms(customer.contact?.phone_number, totpPin); + // } + + return success; + + } catch (error) { + console.error(`Erreur lors de l'envoi du SMS : ${error}`); + return false; + } + } + + /** + * + * 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; + + 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 (validTotpCode.code !== totpCode) throw new InvalidTotpCodeError(); + + // 4: Return the customer + return validTotpCode; + } +} diff --git a/tsconfig.json b/tsconfig.json index bd89006e..bf9fc16e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,11 +4,7 @@ // "module": "es2022", "target": "es2017", "module": "commonjs", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "jsx": "preserve", "sourceMap": true, "outDir": "./dist", @@ -18,7 +14,7 @@ "resolveJsonModule": true, /* Strict Type-Checking Options */ "allowUnreachableCode": false, - "allowUnusedLabels": false, + "allowUnusedLabels": true, "exactOptionalPropertyTypes": false, "noImplicitOverride": true, "strict": true, @@ -32,6 +28,7 @@ "noPropertyAccessFromIndexSignature": true, /* Additional Checks */ "noUnusedLocals": true, + "noUnusedParameters": false, "noImplicitReturns": true, "noUncheckedIndexedAccess": true, "useUnknownInCatchVariables": true, @@ -39,64 +36,37 @@ "moduleResolution": "node", "baseUrl": ".", "paths": { - "@App/*": [ - "src/app/*" - ], - "@Api/*": [ - "src/app/api/*" - ], - "@Services/*": [ - "src/services/*" - ], - "@Repositories/*": [ - "src/common/repositories/*" - ], - "@Entries/*": [ - "src/entries/*" - ], - "@Common/*": [ - "src/common/*" - ], - "@Config/*": [ - "src/common/config/*" - ], - "@Entities/*": [ - "src/common/ressources/*" - ], - "@System/*": [ - "src/common/system/*" - ], - "@ControllerPattern/*": [ - "src/common/system/controller-pattern/*" - ], - "@Test/*": [ - "src/test/*" - ], + "@App/*": ["src/app/*"], + "@Api/*": ["src/app/api/*"], + "@Services/*": ["src/services/*"], + "@Repositories/*": ["src/common/repositories/*"], + "@Entries/*": ["src/entries/*"], + "@Common/*": ["src/common/*"], + "@Config/*": ["src/common/config/*"], + "@Entities/*": ["src/common/ressources/*"], + "@System/*": ["src/common/system/*"], + "@ControllerPattern/*": ["src/common/system/controller-pattern/*"], + "@Test/*": ["src/test/*"] }, - // "rootDirs": [], - // "typeRoots": [], - // "types": [], - // "allowSyntheticDefaultImports": true, + // "rootDirs": [], + // "typeRoots": [], + // "types": [], + // "allowSyntheticDefaultImports": true, "esModuleInterop": true, - // "allowUmdGlobalAccess": true, + // "allowUmdGlobalAccess": true, /* Source Map Options */ - //"sourceRoot": "./src", - //"mapRoot": "./dist", - //"inlineSourceMap": false, - //"inlineSources": false, + //"sourceRoot": "./src", + //"mapRoot": "./dist", + //"inlineSourceMap": false, + //"inlineSources": false, /* Experimental Options */ "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "allowJs": true, - "isolatedModules": true, + "isolatedModules": true }, - "include": [ - "**/*.ts", - "**/*.tsx", "src/services/common/TestService", - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["**/*.ts", "**/*.tsx", "src/services/common/TestService"], + "exclude": ["node_modules"] +}