From 3c284e919a9be626f1072776e5b4dc9fe9f77dc9 Mon Sep 17 00:00:00 2001 From: Benoit Serrano Date: Tue, 10 Dec 2024 17:15:30 +0100 Subject: [PATCH] add user in entities --- package-lock.json | 109 ++++++++++++++++++ package.json | 2 + src/config.ts | 2 + src/dataSource.ts | 3 +- src/lib/hasher.test.ts | 26 +++++ src/lib/hasher.ts | 15 +++ src/lib/signer.test.ts | 25 ++++ src/lib/signer.ts | 22 ++++ src/migrations/1733847009730-add-user.ts | 38 ++++++ src/modules/monitor/Monitor.entity.ts | 6 +- src/modules/systemPulse/SystemPulse.entity.ts | 6 +- src/modules/user/User.entity.ts | 13 +++ src/modules/user/index.ts | 4 + src/modules/user/user.controller.ts | 24 ++++ src/modules/user/user.service.ts | 59 ++++++++++ 15 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 src/lib/hasher.test.ts create mode 100644 src/lib/hasher.ts create mode 100644 src/lib/signer.test.ts create mode 100644 src/lib/signer.ts create mode 100644 src/migrations/1733847009730-add-user.ts create mode 100644 src/modules/user/User.entity.ts create mode 100644 src/modules/user/index.ts create mode 100644 src/modules/user/user.controller.ts create mode 100644 src/modules/user/user.service.ts diff --git a/package-lock.json b/package-lock.json index d50aa80..87f299a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.13", "@types/jest": "^29.5.2", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.3.1", "body-parser": "^1.20.2", "cors": "^2.8.5", @@ -19,6 +20,7 @@ "express": "^4.21.2", "http-status": "^1.6.2", "joi": "^17.9.2", + "jsonwebtoken": "^9.0.2", "path": "^0.12.7", "pg": "^8.8.0", "pg-connection-string": "^2.6.0", @@ -1725,6 +1727,14 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2275,6 +2285,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3012,6 +3027,14 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5767,6 +5790,57 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5807,12 +5881,47 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/package.json b/package.json index 6e3b60d..4aefb59 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.13", "@types/jest": "^29.5.2", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.3.1", "body-parser": "^1.20.2", "cors": "^2.8.5", @@ -39,6 +40,7 @@ "express": "^4.21.2", "http-status": "^1.6.2", "joi": "^17.9.2", + "jsonwebtoken": "^9.0.2", "path": "^0.12.7", "pg": "^8.8.0", "pg-connection-string": "^2.6.0", diff --git a/src/config.ts b/src/config.ts index 9b238cf..8259cfa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,8 @@ if (process.env.DATABASE_URL) { const config = { PORT: process.env.PORT || 3000, HOST_URL: process.env.HOST_URL || '', + JWT_TOKEN_SECRET: process.env.JWT_TOKEN_SECRET || '', + HASH_SECRET: process.env.HASH_SECRET || '', DATABASE_HOST: process.env.DATABASE_HOST || '', DATABASE_PASSWORD: process.env.DATABASE_PASSWORD || '', DATABASE_USER: process.env.DATABASE_USER || '', diff --git a/src/dataSource.ts b/src/dataSource.ts index 4997434..9681770 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -5,6 +5,7 @@ import { SystemPulse } from './modules/systemPulse'; import { Event } from './modules/event'; import { Monitor } from './modules/monitor'; import { MonitorEvent } from './modules/monitorEvent'; +import { User } from './modules/user'; const dataSource = new DataSource({ type: 'postgres', @@ -15,7 +16,7 @@ const dataSource = new DataSource({ database: config.DATABASE_NAME, logging: ['warn', 'error'], connectTimeoutMS: 20000, - entities: [SystemPulse, Event, Monitor, MonitorEvent], + entities: [SystemPulse, Event, Monitor, MonitorEvent, User], subscribers: [], migrations: ['**/migrations/*.js'], }); diff --git a/src/lib/hasher.test.ts b/src/lib/hasher.test.ts new file mode 100644 index 0000000..3382edc --- /dev/null +++ b/src/lib/hasher.test.ts @@ -0,0 +1,26 @@ +import { hasher } from './hasher'; + +describe('hasher', () => { + it('should hash the value', () => { + const value1 = 'Une valeur spécifique'; + const value2 = 'Une autre valeur moins spécifique'; + + const hash1 = hasher.hash(value1); + const hash2 = hasher.hash(value2); + + expect(hash1.length).toBe(64); + expect(hash2.length).toBe(64); + expect(hash1).not.toBe(hash2); + }); + + it('should verify if the hashed value matches', () => { + const value = 'Une valeur spécifique'; + + const result = hasher.verify( + value, + 'af85257f2800d6dfc7ed65d5566d7a159408e27080e5c81e491eb86783daac63', + ); + + expect(result).toBe(true); + }); +}); diff --git a/src/lib/hasher.ts b/src/lib/hasher.ts new file mode 100644 index 0000000..b41440a --- /dev/null +++ b/src/lib/hasher.ts @@ -0,0 +1,15 @@ +import crypto from 'crypto'; +import { config } from '../config'; + +const hasher = { hash, verify }; + +function hash(value: string) { + const hashedValue = crypto.createHmac('sha256', config.HASH_SECRET).update(value).digest('hex'); + return hashedValue; +} + +function verify(value: string, hashedValue: string) { + return hash(value) === hashedValue; +} + +export { hasher }; diff --git a/src/lib/signer.test.ts b/src/lib/signer.test.ts new file mode 100644 index 0000000..8842485 --- /dev/null +++ b/src/lib/signer.test.ts @@ -0,0 +1,25 @@ +import { signer } from './signer'; + +describe('signer', () => { + describe('sign', () => { + it('should create a token', () => { + const payload = { foo: 'bar' }; + + const token = signer.sign(payload); + const [header, body, _] = token.split('.'); + const value = JSON.parse(atob(body))['foo']; + + expect(header).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'); + expect(value).toBe('bar'); + }); + + it('should verify a token', () => { + const payload = { foo: 'bar' }; + + const token = signer.sign(payload); + const value = (signer.verify(token) as Record)['foo']; + + expect(value).toBe('bar'); + }); + }); +}); diff --git a/src/lib/signer.ts b/src/lib/signer.ts new file mode 100644 index 0000000..893ff0a --- /dev/null +++ b/src/lib/signer.ts @@ -0,0 +1,22 @@ +import jwt from 'jsonwebtoken'; +import { config } from '../config'; + +const SIX_MONTHS = 60 * 60 * 24 * 30 * 6; +const ALGORITHM = 'HS256' as const; + +function sign(payload: Object) { + const token = jwt.sign(payload, config.JWT_TOKEN_SECRET, { + algorithm: ALGORITHM, + expiresIn: SIX_MONTHS, + }); + + return token; +} + +function verify(token: string) { + return jwt.verify(token, config.JWT_TOKEN_SECRET, { algorithms: [ALGORITHM] }); +} + +const signer = { sign, verify }; + +export { signer }; diff --git a/src/migrations/1733847009730-add-user.ts b/src/migrations/1733847009730-add-user.ts new file mode 100644 index 0000000..10a0b67 --- /dev/null +++ b/src/migrations/1733847009730-add-user.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUser1733847009730 implements MigrationInterface { + name = 'AddUser1733847009730'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "hashedPassword" character varying NOT NULL, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`, + ); + const [insertedUser] = await queryRunner.query( + `INSERT INTO "user" ("email", "hashedPassword") VALUES ('bartholome.girard@gmail.com', '7b155b65c3ecb88501347988ab889b021c4c891e547976b27e2419734117240b') RETURNING id`, + ); + const userId = insertedUser.id; + await queryRunner.query(`ALTER TABLE "system_pulse" ADD "userId" uuid`); + await queryRunner.query(`ALTER TABLE "monitor" ADD "userId" uuid`); + await queryRunner.query( + `ALTER TABLE "system_pulse" ADD CONSTRAINT "FK_9c9997d98ccc4e43157c9803b0a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "monitor" ADD CONSTRAINT "FK_17b6081f05eee538f7db3de2f9c" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query(`UPDATE "system_pulse" SET "userId"='${userId}'`); + await queryRunner.query(`UPDATE "monitor" SET "userId"='${userId}'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "monitor" DROP CONSTRAINT "FK_17b6081f05eee538f7db3de2f9c"`, + ); + await queryRunner.query( + `ALTER TABLE "system_pulse" DROP CONSTRAINT "FK_9c9997d98ccc4e43157c9803b0a"`, + ); + await queryRunner.query(`ALTER TABLE "monitor" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "system_pulse" DROP COLUMN "userId"`); + await queryRunner.query(`DROP TABLE "user"`); + } +} diff --git a/src/modules/monitor/Monitor.entity.ts b/src/modules/monitor/Monitor.entity.ts index 75e1c7c..3821a49 100644 --- a/src/modules/monitor/Monitor.entity.ts +++ b/src/modules/monitor/Monitor.entity.ts @@ -1,4 +1,5 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '../user'; @Entity() export class Monitor { @@ -22,4 +23,7 @@ export class Monitor { @Column({ type: 'timestamp', nullable: true }) lastCall: string | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true }) + user: User; } diff --git a/src/modules/systemPulse/SystemPulse.entity.ts b/src/modules/systemPulse/SystemPulse.entity.ts index cc1f2a9..17e6e83 100644 --- a/src/modules/systemPulse/SystemPulse.entity.ts +++ b/src/modules/systemPulse/SystemPulse.entity.ts @@ -1,4 +1,5 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '../user'; @Entity() export class SystemPulse { @@ -16,4 +17,7 @@ export class SystemPulse { @Column({ type: 'timestamp', nullable: true }) lastPingedAt: string | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true }) + user: User; } diff --git a/src/modules/user/User.entity.ts b/src/modules/user/User.entity.ts new file mode 100644 index 0000000..d379e6a --- /dev/null +++ b/src/modules/user/User.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + email: string; + + @Column() + hashedPassword: string; +} diff --git a/src/modules/user/index.ts b/src/modules/user/index.ts new file mode 100644 index 0000000..32fefd2 --- /dev/null +++ b/src/modules/user/index.ts @@ -0,0 +1,4 @@ +import { User } from './User.entity'; +import { buildUserController } from './user.controller'; + +export { User, buildUserController }; diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts new file mode 100644 index 0000000..d68061d --- /dev/null +++ b/src/modules/user/user.controller.ts @@ -0,0 +1,24 @@ +import { User } from './User.entity'; +import { buildUserService } from './user.service'; + +export { buildUserController }; + +function buildUserController() { + const userService = buildUserService(); + const userController = { + createUser, + login, + }; + + return userController; + + async function createUser(params: { + body: { email: string; password: string; establishmentName: string; classeName: string }; + }) { + return userService.createUser(params.body); + } + + async function login(params: { body: { email: string; password: string } }) { + return userService.login(params.body.email, params.body.password); + } +} diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts new file mode 100644 index 0000000..ef62f36 --- /dev/null +++ b/src/modules/user/user.service.ts @@ -0,0 +1,59 @@ +import { dataSource } from '../../dataSource'; +import { hasher } from '../../lib/hasher'; +import { signer } from '../../lib/signer'; +import { User } from './User.entity'; + +export { buildUserService }; + +function buildUserService() { + const userRepository = dataSource.getRepository(User); + + const userService = { + createUser, + login, + }; + + return userService; + + async function createUser(params: { email: string; password: string }) { + const newUser = new User(); + newUser.email = params.email; + newUser.hashedPassword = hasher.hash(params.password); + + const result = await userRepository.insert(newUser); + if (result.identifiers.length !== 1) { + throw new Error( + `Something wrong happened. ${result.identifiers.length} users were created.`, + ); + } + newUser.id = result.identifiers[0].id; + + const token = createJwt({ + userId: newUser.id, + email: params.email, + }); + const userInfo = { + email: params.email, + }; + return { token, userInfo }; + } + + function createJwt(params: { userId: User['id']; email: User['email'] }) { + return signer.sign(params); + } + + async function login(email: string, password: string) { + const user = await userRepository.findOneOrFail({ where: { email } }); + + const isPasswordCorrect = hasher.verify(password, user.hashedPassword); + + if (isPasswordCorrect) { + const token = createJwt({ userId: user.id, email: user.email }); + const userInfo = { email }; + + return { token, userInfo }; + } else { + throw new Error(`The password sent does not match the hashed stored password`); + } + } +}