diff --git a/package.json b/package.json index 6b50b10e..b9f73143 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,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.94", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.95", "module-alias": "^2.2.2", "monocle-ts": "^2.3.13", "multer": "^1.4.5-lts.1", @@ -74,6 +75,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..ad48db50 --- /dev/null +++ b/src/app/api/customer/AuthController.ts @@ -0,0 +1,232 @@ +import { Response, Request } from "express"; +import { Controller, Post } from "@ControllerPattern/index"; +import ApiController from "@Common/system/controller-pattern/ApiController"; +import { Service } from "typedi"; +import { EnrollmentResponse } from "@Services/common/Id360Service/Id360Service"; +import CustomersService from "@Services/customer/CustomersService/CustomersService"; +import AuthService, { ICustomerJwtPayload } 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/pre-login") + protected async preLogin(req: Request, response: Response) { + const email = req.body["email"]; + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + let customer = await this.customerService.getOne({ + where: { + contact: { + email, + }, + }, + include: { + contact: true, + }, + }); + + if (!customer) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + + // if code has more than 5mn, regenerate it + if ( + !customer.smsCodeExpire || + (customer.smsCodeExpire && new Date().getTime() - customer.smsCodeExpire.getTime() > 5 * 60 * 1000) + ) { + customer = await this.customerService.generateSmsCode(customer); + } + + if (!customer.password) { + try { + this.httpSuccess(response, { info: "Sending a sms for first connection" }); + } catch (error) { + console.log(error); + this.httpInternalError(response); + } + return; + } + + try { + this.httpSuccess(response, { email, customer }); + } catch (error) { + console.log(error); + this.httpInternalError(response); + return; + } + } + + @Post("/api/v1/customer/login") + protected async login(req: Request, response: Response) { + const email = req.body["email"]; + const password = req.body["password"]; + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + if (!password) { + this.httpBadRequest(response, "Password is required"); + return; + } + + let customer = await this.customerService.getOne({ + where: { + contact: { + email, + }, + }, + include: { + contact: true, + }, + }); + + if (!customer) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + + if (!customer.password) { + this.httpBadRequest(response, "Customer not registered"); + return; + } + + // compare password to the hash + const isPasswordValid = await this.authService.comparePassword(password, customer.password); + if (!isPasswordValid) { + this.httpBadRequest(response, "Invalid password"); + return; + } + + try { + this.httpSuccess(response, { customer }); + } catch (error) { + console.log(error); + this.httpInternalError(response); + return; + } + } + + @Post("/api/v1/customer/set-password") + protected async setPassword(req: Request, response: Response) { + const email = req.body["email"]; + const smsCode = req.body["smsCode"]; + const password = req.body["password"]; + + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + if (!smsCode) { + this.httpBadRequest(response, "Sms code is required"); + return; + } + + if (!password) { + this.httpBadRequest(response, "Password is required"); + return; + } + + const customer = await this.customerService.getOne({ + where: { + contact: { + email, + }, + }, + include: { + contact: true, + }, + }); + + if (!customer) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + + if (!customer.smsCode) { + this.httpBadRequest(response, "No sms code found"); + return; + } + + if (customer.smsCode !== smsCode) { + this.httpBadRequest(response, "Invalid sms code"); + return; + } + + if (customer.password) { + this.httpBadRequest(response, "Password already set"); + return; + } + + const hashedPassword = await this.authService.hashPassword(password); + await this.customerService.setPassword(customer, hashedPassword); + + try { + this.httpSuccess(response, { email }); + } catch (error) { + console.log(error); + this.httpInternalError(response); + return; + } + } + + @Post("/api/v1/customer/check-sms-code") + protected async checkSmsCode(req: Request, response: Response) { + const email = req.body["email"]; + const smsCode = req.body["smsCode"]; + + if (!email) { + this.httpBadRequest(response, "Email is required"); + return; + } + + if (!smsCode) { + this.httpBadRequest(response, "Sms code is required"); + return; + } + + const customer = await this.customerService.getOne({ + where: { + contact: { + email, + }, + }, + include: { + contact: true, + }, + }); + + if (!customer) { + this.httpNotFoundRequest(response, "Customer not found"); + return; + } + + if (!customer.smsCode) { + this.httpBadRequest(response, "No sms code found"); + return; + } + + if (customer.smsCode !== smsCode) { + this.httpBadRequest(response, "Invalid sms code"); + return; + } + + try { + this.httpSuccess(response, { success: "success" }); + } catch (error) { + console.log(error); + this.httpInternalError(response); + return; + } + } +} 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/databases/migrations/20231123104754_customer_login/migration.sql b/src/common/databases/migrations/20231123104754_customer_login/migration.sql new file mode 100644 index 00000000..0bf90ced --- /dev/null +++ b/src/common/databases/migrations/20231123104754_customer_login/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "password" VARCHAR(255), +ADD COLUMN "passwordCode" VARCHAR(255), +ADD COLUMN "passwordcodeExpire" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "smsCode" VARCHAR(255), +ADD COLUMN "smsCodeExpire" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP; diff --git a/src/common/databases/schema.prisma b/src/common/databases/schema.prisma index dcd5a5b0..74b5b9c5 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -101,6 +101,12 @@ model Customers { updated_at DateTime? @updatedAt office_folders OfficeFolders[] @relation("OfficeFolderHasCustomers") documents Documents[] + password String? @db.VarChar(255) + smsCode String? @db.VarChar(255) + smsCodeExpire DateTime? @default(now()) + passwordCode String? @db.VarChar(255) + passwordcodeExpire DateTime? @default(now()) + @@map("customers") } diff --git a/src/common/repositories/CustomersRepository.ts b/src/common/repositories/CustomersRepository.ts index 8a27293a..d9acaef5 100644 --- a/src/common/repositories/CustomersRepository.ts +++ b/src/common/repositories/CustomersRepository.ts @@ -25,6 +25,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 } } } }); + } + /** * @description : Create a customer */ @@ -79,6 +88,11 @@ export default class CustomersRepository extends BaseRepository { address: {}, }, }, + smsCode: customer.smsCode, + smsCodeExpire: customer.smsCodeExpire, + passwordCode: customer.passwordCode, + passwordcodeExpire: customer.passwordCodeExpire, + password: customer.password, }, }; if (customer.contact!.address) { @@ -88,6 +102,7 @@ export default class CustomersRepository extends BaseRepository { city: customer.contact!.address!.city, }; } + return this.model.update({ ...updateArgs, include: { contact: true } }); } 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/customer/CustomersService/CustomersService.ts b/src/services/customer/CustomersService/CustomersService.ts index 8ec5f672..6461e6e4 100644 --- a/src/services/customer/CustomersService/CustomersService.ts +++ b/src/services/customer/CustomersService/CustomersService.ts @@ -1,6 +1,7 @@ import { Customers, Prisma } from "@prisma/client"; import CustomersRepository from "@Repositories/CustomersRepository"; import BaseService from "@Services/BaseService"; +import { Customer } from "le-coffre-resources/dist/Notary"; import { Service } from "typedi"; @Service() @@ -16,4 +17,44 @@ 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 : Generate a SMS code for a customer + * @throws {Error} + */ + public async generateSmsCode(customer: Customer) { + const smsCode = Math.floor(100000 + Math.random() * 900000); + return await this.customerRepository.update( + customer.uid as string, + Customer.hydrate({ + ...customer, + smsCode: smsCode.toString(), + smsCodeExpire: new Date(), + }), + ); + } + + /** + * @description : Set password for a customer + * @throws {Error} If customer cannot be updated + */ + public async setPassword(customer: Customer, password: string) { + return await this.customerRepository.update( + customer.uid as string, + Customer.hydrate({ + ...customer, + password, + smsCode: null, + smsCodeExpire: null, + }), + ); + } +} diff --git a/tsconfig.json b/tsconfig.json index bd89006e..bbd8ba4d 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, @@ -31,7 +27,8 @@ "alwaysStrict": true, "noPropertyAccessFromIndexSignature": true, /* Additional Checks */ - "noUnusedLocals": true, + "noUnusedLocals": false, + "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"] +}