From fcb67e1951c843dfe653a82ba0d70e9df51a03b4 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:32:11 +0300 Subject: [PATCH 01/18] =?UTF-8?q?8.1.=20=D0=A3=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=20`jo?= =?UTF-8?q?se`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Следующим шагом добавим в приложение поддержку авторизации. Есть разные способы решения этой задачи. Мы воспользуемся JWT (JSON Web Token). Для работы с JWT есть несколько готовых пакетов. Наиболее популярный — `jsonwebtoken`. Он поддерживает большое количество алгоритмов шифрования и можно сказать проверен временем. Однако, у него не очень удобный интерфейс. Он построен на функциях обратного вызова, колбеках. Можно самостоятельно сделать все необходимые обёртки, но мы пойдём немного другим путём. Воспользуемся более современным вариантом — `jose`. С точки зрения функциональности пакеты похожие. `jose` поддерживает меньше алгоритмов шифрования, но зато предоставляет удобный программный интерфейс. Воспользуемся им. При желании вы можете самостоятельно экспериментировать с `jsonwebtoken`. В нашем учебнике есть подробная статья на эту тему. Установим пакет `jose` в качестве основной зависимости. Типы поставляются из коробки, поэтому дополнительно их указывать не нужно. --- package-lock.json | 14 ++++++++++++++ package.json | 1 + 2 files changed, 15 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9b2b249..cc5ae68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "got": "13.0.0", "http-status-codes": "2.3.0", "inversify": "6.0.1", + "jose": "4.15.4", "mime-types": "2.1.35", "mongoose": "7.5.3", "multer": "1.4.5-lts.1", @@ -4192,6 +4193,14 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -10202,6 +10211,11 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==" + }, "joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/package.json b/package.json index e8e13d4..435ccee 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "got": "13.0.0", "http-status-codes": "2.3.0", "inversify": "6.0.1", + "jose": "4.15.4", "mime-types": "2.1.35", "mongoose": "7.5.3", "multer": "1.4.5-lts.1", From 6ea4ade39c7e1ae46cf171d5e02c01d83937576f Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:36:10 +0300 Subject: [PATCH 02/18] =?UTF-8?q?8.2.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20`JWT=5FSECRET`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При подготовке токена нам потребуется подготовить секрет — строка, которая будет использоваться в процессе шифрования. За время работы приложения она может меняться. Будем передавать её в переменной окружения. Заведём в конфигурации отдельную переменную окружения `JWT_SECRET`. Значения по умолчанию не будет (`null`). Затем добавим её в `.env`. --- src/shared/libs/config/rest.schema.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index b5f038f..4bdb0e0 100644 --- a/src/shared/libs/config/rest.schema.ts +++ b/src/shared/libs/config/rest.schema.ts @@ -12,6 +12,7 @@ export type RestSchema = { DB_PORT: string; DB_NAME: string; UPLOAD_DIRECTORY: string; + JWT_SECRET: string; } export const configRestSchema = convict({ @@ -63,4 +64,10 @@ export const configRestSchema = convict({ env: 'UPLOAD_DIRECTORY', default: null }, + JWT_SECRET: { + doc: 'Secret for sign JWT', + format: String, + env: 'JWT_SECRET', + default: null + }, }); From 2487c3ffbeee1c0ed483ec5447111ed094bad323 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:36:32 +0300 Subject: [PATCH 03/18] =?UTF-8?q?8.3.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20`verifyPassword?= =?UTF-8?q?`=20=D0=B2=20`UserEntity`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В классе `UserEntity` мы реализовали метод `setPassword` для формирования хеша на основе пароля. Пришло время добавить дополнительный метод, который сможет сравнить пароли. Идея в следующем. Чтобы проверить, что пользователь тот за кого себя выдаёт, мы запросим у него пароль. Полученный пароль хешируем с помощью функции `createSHA256`, а затем сравним его с хешем, который сохранён в базе. Если хэши паролей совпадают, значит пользователь ввёл корректный пароль и его можно пропускать дальше. --- src/shared/modules/user/user.entity.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/modules/user/user.entity.ts b/src/shared/modules/user/user.entity.ts index 1618bfd..2bde8b2 100644 --- a/src/shared/modules/user/user.entity.ts +++ b/src/shared/modules/user/user.entity.ts @@ -44,6 +44,11 @@ export class UserEntity extends defaultClasses.TimeStamps implements User { public getPassword() { return this.password; } + + public verifyPassword(password: string, salt: string) { + const hashPassword = createSHA256(password, salt); + return hashPassword === this.password; + } } export const UserModel = getModelForClass(UserEntity); From 7f0830ae6dccd43f1f2ac81f6ad4cdfb875279ed Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:37:22 +0300 Subject: [PATCH 04/18] =?UTF-8?q?8.4.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20`AuthService`=20=D0=B8=20`DefaultAuthService`.?= =?UTF-8?q?=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для реализации проверки логина и пароля, а также формирования токена подготовим отдельный модуль `Auth`. В модуле предусмотрим методы * `authenticate`. Аргументом принимает `UserEntity`. Результатом становится токен. * `verify`. Выполняет аутентификацию. На вход принимает `LoginUserDto`. Результатом станет `UserEntity` или ошибка. В этом же коммите добавим константы `JWT_ALGORITHM` и `JWT_EXPIRED`. В первой зафиксируем алгоритм для формирования токена, а во второй время, в течение которого токен считается валидным. WIP: Задача в процесс решения. --- .../modules/auth/auth-service.interface.ts | 6 ++ src/shared/modules/auth/auth.constant.ts | 2 + .../modules/auth/default-auth.service.ts | 55 +++++++++++++++++++ src/shared/modules/auth/types/TokenPayload.ts | 6 ++ 4 files changed, 69 insertions(+) create mode 100644 src/shared/modules/auth/auth-service.interface.ts create mode 100644 src/shared/modules/auth/auth.constant.ts create mode 100644 src/shared/modules/auth/default-auth.service.ts create mode 100644 src/shared/modules/auth/types/TokenPayload.ts diff --git a/src/shared/modules/auth/auth-service.interface.ts b/src/shared/modules/auth/auth-service.interface.ts new file mode 100644 index 0000000..9d899c5 --- /dev/null +++ b/src/shared/modules/auth/auth-service.interface.ts @@ -0,0 +1,6 @@ +import { LoginUserDto, UserEntity } from '../user/index.js'; + +export interface AuthService { + authenticate(user: UserEntity): Promise; + verify(dto: LoginUserDto): Promise; +} diff --git a/src/shared/modules/auth/auth.constant.ts b/src/shared/modules/auth/auth.constant.ts new file mode 100644 index 0000000..269747d --- /dev/null +++ b/src/shared/modules/auth/auth.constant.ts @@ -0,0 +1,2 @@ +export const JWT_ALGORITHM = 'HS256'; +export const JWT_EXPIRED = '2d'; diff --git a/src/shared/modules/auth/default-auth.service.ts b/src/shared/modules/auth/default-auth.service.ts new file mode 100644 index 0000000..b786ddc --- /dev/null +++ b/src/shared/modules/auth/default-auth.service.ts @@ -0,0 +1,55 @@ +import { inject, injectable } from 'inversify'; +import * as crypto from 'node:crypto'; +import { SignJWT } from 'jose'; +import { AuthService } from './auth-service.interface.js'; +import { Component } from '../../types/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { LoginUserDto, UserEntity, UserService } from '../user/index.js'; +import { TokenPayload } from './types/TokenPayload.js'; +import { Config, RestSchema } from '../../libs/config/index.js'; +import { UserNotFoundException, UserPasswordIncorrectException } from './errors/index.js'; +import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; + +@injectable() +export class DefaultAuthService implements AuthService { + constructor( + @inject(Component.Logger) private readonly logger: Logger, + @inject(Component.UserService) private readonly userService: UserService, + @inject(Component.Config) private readonly config: Config, + ) {} + + public async authenticate(user: UserEntity): Promise { + const jwtSecret = this.config.get('JWT_SECRET'); + const secretKey = crypto.createSecretKey(jwtSecret, 'utf-8'); + const tokenPayload: TokenPayload = { + email: user.email, + firstname: user.firstname, + lastname: user.lastname, + id: user.id, + }; + + this.logger.info(`Create token for ${user.email}`); + return new SignJWT(tokenPayload) + .setProtectedHeader({ alg: JWT_ALGORITHM }) + .setIssuedAt() + .setExpirationTime(JWT_EXPIRED) + .sign(secretKey); + } + + public async verify(dto: LoginUserDto): Promise { + const user = await this.userService.findByEmail(dto.email); + if (! user) { + this.logger.warn(`User with ${dto.email} not found`); + throw new UserNotFoundException(); + } + + if (! user.verifyPassword(dto.password, this.config.get('SALT'))) { + this.logger.warn(`Incorrect password for ${dto.email}`); + throw new UserPasswordIncorrectException(); + } + + return user; + } + + +} diff --git a/src/shared/modules/auth/types/TokenPayload.ts b/src/shared/modules/auth/types/TokenPayload.ts new file mode 100644 index 0000000..b4c88cb --- /dev/null +++ b/src/shared/modules/auth/types/TokenPayload.ts @@ -0,0 +1,6 @@ +export type TokenPayload = { + email: string; + firstname: string; + lastname: string; + id: string; +}; From 4fa7dd6953e6156853d34d7af8f187883ab0200a Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:37:53 +0300 Subject: [PATCH 05/18] =?UTF-8?q?8.5.=20=D0=9D=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20`UserNotFoundException`?= =?UTF-8?q?=20=D0=B8=20`UserPasswordIncorrectException`.=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При выполнении процедуры аутентификации могут возникнуть различные ошибки. Например, пользователь может быть не обнаружен в базе данных. Или его пароль неправильный. Это только два частых кейса, но их быть может быть много. Есть разные способы обработки подобных ситуаций. Методы сервиса `Auth` могут возвращать, например, значение `null`, если пользователь не найден. Можно обработать это значение где-то в месте обращения к серверу и подготовить соответствующий ответ клиенту. Можно пойти другим путём и описать отдельные виды ошибок. Определяем для каждой ситуации ошибки, бросаем их при наступлении определённого события и дальше где-нибудь их обрабатываем. --- src/shared/modules/auth/errors/base-user.exception.ts | 7 +++++++ src/shared/modules/auth/errors/index.ts | 3 +++ .../modules/auth/errors/user-not-found.exception.ts | 8 ++++++++ .../auth/errors/user-password-incorrect.exception.ts | 8 ++++++++ 4 files changed, 26 insertions(+) create mode 100644 src/shared/modules/auth/errors/base-user.exception.ts create mode 100644 src/shared/modules/auth/errors/index.ts create mode 100644 src/shared/modules/auth/errors/user-not-found.exception.ts create mode 100644 src/shared/modules/auth/errors/user-password-incorrect.exception.ts diff --git a/src/shared/modules/auth/errors/base-user.exception.ts b/src/shared/modules/auth/errors/base-user.exception.ts new file mode 100644 index 0000000..27f8f1b --- /dev/null +++ b/src/shared/modules/auth/errors/base-user.exception.ts @@ -0,0 +1,7 @@ +import { HttpError } from '../../../libs/rest/index.js'; + +export class BaseUserException extends HttpError { + constructor(httpStatusCode: number, message: string) { + super(httpStatusCode, message); + } +} diff --git a/src/shared/modules/auth/errors/index.ts b/src/shared/modules/auth/errors/index.ts new file mode 100644 index 0000000..e1154ae --- /dev/null +++ b/src/shared/modules/auth/errors/index.ts @@ -0,0 +1,3 @@ +export * from './base-user.exception.js'; +export * from './user-not-found.exception.js'; +export * from './user-password-incorrect.exception.js'; diff --git a/src/shared/modules/auth/errors/user-not-found.exception.ts b/src/shared/modules/auth/errors/user-not-found.exception.ts new file mode 100644 index 0000000..d5833d0 --- /dev/null +++ b/src/shared/modules/auth/errors/user-not-found.exception.ts @@ -0,0 +1,8 @@ +import { StatusCodes } from 'http-status-codes'; +import { BaseUserException } from './base-user.exception.js'; + +export class UserNotFoundException extends BaseUserException { + constructor() { + super(StatusCodes.NOT_FOUND, 'User not found'); + } +} diff --git a/src/shared/modules/auth/errors/user-password-incorrect.exception.ts b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts new file mode 100644 index 0000000..8579750 --- /dev/null +++ b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts @@ -0,0 +1,8 @@ +import { StatusCodes } from 'http-status-codes'; +import { BaseUserException } from './base-user.exception.js'; + +export class UserPasswordIncorrectException extends BaseUserException { + constructor() { + super(StatusCodes.UNAUTHORIZED, 'Incorrect user name or password'); + } +} From 2b2393432be0505a1993c04909f4c1c06cabb7df Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:38:36 +0300 Subject: [PATCH 06/18] =?UTF-8?q?8.6.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20`AuthExceptionFilter`.=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit А как обработать отдельные ошибки? Те, что были созданы в предыдущем коммите. Можно расширить ранее созданный `AppExceptionFilter`. Мы можем добавить отдельные методы и обрабатывать по-своему новые виды ошибок. Однако, этот подход отразится на поддержке `AppExceptionFilter`. Он станет слишком большим. Выход из этой ситуации: создание отдельного `AuthExceptionFilter`. Разместим его в директории `Auth`. --- .../modules/auth/auth.exception-filter.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/shared/modules/auth/auth.exception-filter.ts diff --git a/src/shared/modules/auth/auth.exception-filter.ts b/src/shared/modules/auth/auth.exception-filter.ts new file mode 100644 index 0000000..500a134 --- /dev/null +++ b/src/shared/modules/auth/auth.exception-filter.ts @@ -0,0 +1,28 @@ +import { inject, injectable } from 'inversify'; +import { NextFunction, Request, Response } from 'express'; +import { ExceptionFilter } from '../../libs/rest/index.js'; +import { Component } from '../../types/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { BaseUserException } from './errors/index.js'; + +@injectable() +export class AuthExceptionFilter implements ExceptionFilter { + constructor( + @inject(Component.Logger) private readonly logger: Logger + ) { + this.logger.info('Register AuthExceptionFilter'); + } + + public catch(error: unknown, _req: Request, res: Response, next: NextFunction): void { + if (! (error instanceof BaseUserException)) { + return next(error); + } + + this.logger.error(`[AuthModule] ${error.message}`, error); + res.status(error.httpStatusCode) + .json({ + type: 'AUTHORIZATION', + error: error.message, + }); + } +} From df7642bae3b49ad1eca3576999a6abb014e4397e Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:39:39 +0300 Subject: [PATCH 07/18] =?UTF-8?q?8.7.=20=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=B8=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=D0=BD=D0=B5=D1=80=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D1=81=D1=82=D0=B5=D0=B9=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20`Auth`.=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Теперь подготовим инфраструктуру для нового модуля `Auth`. Соберём все необходимые зависимости в контейнере, внедрим нужные компоненты в `RestApplication`, зарегистрируем новый фильтр исключений, добавим компоненты в `Component` и так далее. --- src/main.rest.ts | 2 ++ src/rest/rest.application.ts | 2 ++ src/shared/modules/auth/auth.container.ts | 14 ++++++++++++++ src/shared/modules/auth/index.ts | 4 ++++ src/shared/types/component.enum.ts | 2 ++ 5 files changed, 24 insertions(+) create mode 100644 src/shared/modules/auth/auth.container.ts create mode 100644 src/shared/modules/auth/index.ts diff --git a/src/main.rest.ts b/src/main.rest.ts index 734e0d5..f2a0693 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -7,6 +7,7 @@ import { createUserContainer } from './shared/modules/user/index.js'; import { createCategoryContainer } from './shared/modules/category/index.js'; import { createOfferContainer } from './shared/modules/offer/index.js'; import { createCommentContainer } from './shared/modules/comment/index.js'; +import { createAuthContainer } from './shared/modules/auth/index.js'; async function bootstrap() { @@ -16,6 +17,7 @@ async function bootstrap() { createCategoryContainer(), createOfferContainer(), createCommentContainer(), + createAuthContainer(), ); const application = appContainer.get(Component.RestApplication); diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index b151c72..b514fee 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -20,6 +20,7 @@ export class RestApplication { @inject(Component.UserController) private readonly userController: Controller, @inject(Component.OfferController) private readonly offerController: Controller, @inject(Component.CommentController) private readonly commentController: Controller, + @inject(Component.AuthExceptionFilter) private readonly authExceptionFilter: ExceptionFilter, ) { this.server = express(); } @@ -57,6 +58,7 @@ export class RestApplication { } private async _initExceptionFilters() { + this.server.use(this.authExceptionFilter.catch.bind(this.authExceptionFilter)); this.server.use(this.appExceptionFilter.catch.bind(this.appExceptionFilter)); } diff --git a/src/shared/modules/auth/auth.container.ts b/src/shared/modules/auth/auth.container.ts new file mode 100644 index 0000000..9c83d63 --- /dev/null +++ b/src/shared/modules/auth/auth.container.ts @@ -0,0 +1,14 @@ +import { Container } from 'inversify'; +import { AuthService } from './auth-service.interface.js'; +import { Component } from '../../types/index.js'; +import { DefaultAuthService } from './default-auth.service.js'; +import { ExceptionFilter } from '../../libs/rest/index.js'; +import { AuthExceptionFilter } from './auth.exception-filter.js'; + +export function createAuthContainer() { + const authContainer = new Container(); + authContainer.bind(Component.AuthService).to(DefaultAuthService).inSingletonScope(); + authContainer.bind(Component.AuthExceptionFilter).to(AuthExceptionFilter).inSingletonScope(); + + return authContainer; +} diff --git a/src/shared/modules/auth/index.ts b/src/shared/modules/auth/index.ts new file mode 100644 index 0000000..72d9044 --- /dev/null +++ b/src/shared/modules/auth/index.ts @@ -0,0 +1,4 @@ +export * from './auth-service.interface.js'; +export * from './types/TokenPayload.js'; +export * from './auth.container.js'; +export * from './default-auth.service.js'; diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index 1343b47..2c17bb2 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -16,4 +16,6 @@ export const Component = { UserController: Symbol.for('UserController'), OfferController: Symbol.for('OfferController'), CommentController: Symbol.for('CommentController'), + AuthService: Symbol.for('AuthService'), + AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), } as const; From 52c4d64316c34de93e0113b804094320833ece27 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:40:25 +0300 Subject: [PATCH 08/18] =?UTF-8?q?8.8.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20`login`=20=D0=B2=20`UserController`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Теперь внедрим в качестве зависимости новый сервис в `UserController` и имплементируем обработчик `login`. Если всё прошло хорошо, то он вернёт новый токен. Проверить токен на корректность и посмотреть его содержимое вы можете на сайте https://jwt.io/. При тестировании попробуйте воспроизвести негативные сценарии: отправьте некорректные логин или пароль. `AuthExceptionFilter` должен перехватить ошибки. --- src/shared/modules/user/index.ts | 2 ++ .../modules/user/rdo/logged-user.rdo.ts | 9 +++++++ src/shared/modules/user/user.controller.ts | 27 ++++++++----------- 3 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 src/shared/modules/user/rdo/logged-user.rdo.ts diff --git a/src/shared/modules/user/index.ts b/src/shared/modules/user/index.ts index 708a8aa..a9e658f 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -3,3 +3,5 @@ export * from './dto/create-user.dto.js'; export * from './default-user.service.js'; export * from './user.container.js'; export * from './user.controller.js'; +export * from './dto/login-user.dto.js'; +export * from './user-service.interface.js'; diff --git a/src/shared/modules/user/rdo/logged-user.rdo.ts b/src/shared/modules/user/rdo/logged-user.rdo.ts new file mode 100644 index 0000000..543bd7d --- /dev/null +++ b/src/shared/modules/user/rdo/logged-user.rdo.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class LoggedUserRdo { + @Expose() + public token: string; + + @Expose() + public email: string; +} diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index a2f7ee8..311a3b4 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -18,6 +18,8 @@ import { UserRdo } from './rdo/user.rdo.js'; import { LoginUserRequest } from './login-user-request.type.js'; import { CreateUserDto } from './dto/create-user.dto.js'; import { LoginUserDto } from './dto/login-user.dto.js'; +import { AuthService } from '../auth/index.js'; +import { LoggedUserRdo } from './rdo/logged-user.rdo.js'; @injectable() export class UserController extends BaseController { @@ -25,6 +27,7 @@ export class UserController extends BaseController { @inject(Component.Logger) protected readonly logger: Logger, @inject(Component.UserService) private readonly userService: UserService, @inject(Component.Config) private readonly configService: Config, + @inject(Component.AuthService) private readonly authService: AuthService, ) { super(logger); this.logger.info('Register routes for UserController…'); @@ -72,23 +75,15 @@ export class UserController extends BaseController { public async login( { body }: LoginUserRequest, - _res: Response, + res: Response, ): Promise { - const existsUser = await this.userService.findByEmail(body.email); - - if (! existsUser) { - throw new HttpError( - StatusCodes.UNAUTHORIZED, - `User with email ${body.email} not found.`, - 'UserController', - ); - } - - throw new HttpError( - StatusCodes.NOT_IMPLEMENTED, - 'Not implemented', - 'UserController', - ); + const user = await this.authService.verify(body); + const token = await this.authService.authenticate(user); + const responseData = fillDTO(LoggedUserRdo, { + email: user.email, + token, + }); + this.ok(res, responseData); } public async uploadAvatar(req: Request, res: Response) { From b1d30928daf3df622fbd32378a262e956dd85032 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 18:49:28 +0300 Subject: [PATCH 09/18] =?UTF-8?q?8.9.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20`ParseTokenMiddleware`.=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Токены формируются. Теперь надо как-то извлечь информацию из `payload` токена. Это можно делать на уровне обработчика маршрута, но в этом случае, один и тот же код придётся дублировать в разных местах приложения. Чтобы этого избежать, создадим `ParseTokenMiddleware`, которую зарегистрируем глобально. Если к запросу будет приложен токен, то middleware сможет извлечь из него информацию и добавить к запросу. Начнём с извлечения информации. Сначала проверим наличие заголовка `authorization`. Если он есть, значит продолжаем работать. Затем извлечём сам токен. После этого проверим с помощью `jwtVerify`, Если всё ок, то сохраним в свойство запроса `user`. Таким образом, когда запрос долетит до обработчика маршрута, у нас будет вся необходимая информация. --- .../rest/middleware/parse-token.middleware.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/shared/libs/rest/middleware/parse-token.middleware.ts diff --git a/src/shared/libs/rest/middleware/parse-token.middleware.ts b/src/shared/libs/rest/middleware/parse-token.middleware.ts new file mode 100644 index 0000000..89af22a --- /dev/null +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -0,0 +1,48 @@ +import { NextFunction, Request, Response } from 'express'; +import { jwtVerify } from 'jose'; +import { StatusCodes } from 'http-status-codes'; +import { createSecretKey } from 'node:crypto'; +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; +import { TokenPayload } from '../../../modules/auth/index.js'; + +function isTokenPayload(payload: unknown): payload is TokenPayload { + return ( + (typeof payload === 'object' && payload !== null) && + ('email' in payload && typeof payload.email === 'string') && + ('firstname' in payload && typeof payload.firstname === 'string') && + ('lastname' in payload && typeof payload.lastname === 'string') && + ('id' in payload && typeof payload.id === 'string') + ); +} + +export class ParseTokenMiddleware implements Middleware { + constructor(private readonly jwtSecret: string) {} + + public async execute(req: Request, _res: Response, next: NextFunction): Promise { + const authorizationHeader = req.headers?.authorization?.split(' '); + if (!authorizationHeader) { + return next(); + } + + const [, token] = authorizationHeader; + + try { + const { payload } = await jwtVerify(token, createSecretKey(this.jwtSecret, 'utf-8')); + + if (isTokenPayload(payload)) { + req.tokenPayload = { ...payload }; + return next(); + } else { + throw new Error('Bad token'); + } + } catch { + + return next(new HttpError( + StatusCodes.UNAUTHORIZED, + 'Invalid token', + 'AuthenticateMiddleware') + ); + } + } +} From dd150df71760393d186c52282e834be8dad5308f Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:09:23 +0300 Subject: [PATCH 10/18] =?UTF-8?q?8.10.=20=D0=A0=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D1=82=20=D1=82=D0=B8=D0=BF=20`Request`.=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создадим файл `custom.d.ts` и расширим информацию о типе `Request`. Обновим конфигурационный файл `tsconfig.json`. --- custom.d.ts | 7 +++++++ tsconfig.json | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 custom.d.ts diff --git a/custom.d.ts b/custom.d.ts new file mode 100644 index 0000000..778a665 --- /dev/null +++ b/custom.d.ts @@ -0,0 +1,7 @@ +import { TokenPayload } from './src/shared/modules/auth/index.js'; + +declare module 'express-serve-static-core' { + export interface Request { + tokenPayload: TokenPayload; + } +} diff --git a/tsconfig.json b/tsconfig.json index dbd5167..20a2ce3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,11 +22,11 @@ "outDir": "./dist", "emitDecoratorMetadata": true, "types": [ - "node", + "node" ], "lib": [ "ESNext" - ] + ], }, "include": [ "src/**/*.ts" @@ -37,5 +37,6 @@ ], "ts-node": { "esm": true - } + }, + "files": ["./custom.d.ts"] } From 48834e54e7aee7c2d79d676098040483fbb27225 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:10:02 +0300 Subject: [PATCH 11/18] =?UTF-8?q?8.11.=20=D0=9F=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=20`ParseTokenMiddleware`=20=D0=B3?= =?UTF-8?q?=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rest/rest.application.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index b514fee..2e574e3 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -6,6 +6,7 @@ import { Component } from '../shared/types/index.js'; import { DatabaseClient } from '../shared/libs/database-client/index.js'; import { getMongoURI } from '../shared/helpers/index.js'; import { Controller, ExceptionFilter } from '../shared/libs/rest/index.js'; +import { ParseTokenMiddleware } from '../shared/libs/rest/middleware/parse-token.middleware.js'; @injectable() export class RestApplication { @@ -50,11 +51,14 @@ export class RestApplication { } private async _initMiddleware() { + const authenticateMiddleware = new ParseTokenMiddleware(this.config.get('JWT_SECRET')); + this.server.use(express.json()); this.server.use( '/upload', express.static(this.config.get('UPLOAD_DIRECTORY')) ); + this.server.use(authenticateMiddleware.execute.bind(authenticateMiddleware)); } private async _initExceptionFilters() { From b0e46bd10f06e0861890f5705133d26b7e85452f Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:10:52 +0300 Subject: [PATCH 12/18] =?UTF-8?q?8.12.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20`CommentController`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Теперь воспользуемся middleware и будем получать ID пользователя автоматически. --- src/shared/modules/comment/comment.controller.ts | 5 ++--- src/shared/modules/comment/dto/create-comment.dto.ts | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index e2e4f7b..8a60f46 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -32,10 +32,9 @@ export default class CommentController extends BaseController { } public async create( - { body }: CreateCommentRequest, + { body, tokenPayload }: CreateCommentRequest, res: Response ): Promise { - if (! await this.offerService.exists(body.offerId)) { throw new HttpError( StatusCodes.NOT_FOUND, @@ -44,7 +43,7 @@ export default class CommentController extends BaseController { ); } - const comment = await this.commentService.create(body); + const comment = await this.commentService.create({ ...body, userId: tokenPayload.id }); await this.offerService.incCommentCount(body.offerId); this.created(res, fillDTO(CommentRdo, comment)); } diff --git a/src/shared/modules/comment/dto/create-comment.dto.ts b/src/shared/modules/comment/dto/create-comment.dto.ts index 39843ff..7a7dbd1 100644 --- a/src/shared/modules/comment/dto/create-comment.dto.ts +++ b/src/shared/modules/comment/dto/create-comment.dto.ts @@ -9,6 +9,5 @@ export class CreateCommentDto { @IsMongoId({ message: CreateCommentMessages.offerId.invalidFormat }) public offerId: string; - @IsMongoId({ message: CreateCommentMessages.userId.invalidFormat }) public userId: string; } From 6e24d133366736b9b953e141f8255e2ab50888b3 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:11:12 +0300 Subject: [PATCH 13/18] =?UTF-8?q?8.13.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20`PrivateRouteMiddleware`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Токены работают, но по-прежнему есть проблема: клиент может обращаться ко всем маршрутам. Такого быть не должно. Некоторые маршруты априори приватны. Реализуем дополнительную проверку. Для этого создадим новый middleware — `PrivateRouteMiddleware` и в нём выполним проверку. Если в объекте запроса нет объекта `tokenPayload`, значит не авторизованы. --- .../middleware/private-route.middleware.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/shared/libs/rest/middleware/private-route.middleware.ts diff --git a/src/shared/libs/rest/middleware/private-route.middleware.ts b/src/shared/libs/rest/middleware/private-route.middleware.ts new file mode 100644 index 0000000..3c73a91 --- /dev/null +++ b/src/shared/libs/rest/middleware/private-route.middleware.ts @@ -0,0 +1,18 @@ +import { StatusCodes } from 'http-status-codes'; +import { NextFunction, Request, Response } from 'express'; +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; + +export class PrivateRouteMiddleware implements Middleware { + public async execute({ tokenPayload }: Request, _res: Response, next: NextFunction): Promise { + if (! tokenPayload) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'PrivateRouteMiddleware' + ); + } + + return next(); + } +} From ac73fb5f77bb9e6315eb585609c2ceabc5069717 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:11:30 +0300 Subject: [PATCH 14/18] =?UTF-8?q?8.14.=20=D0=9F=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=20`PrivateRouteMiddleware`=20=D0=B2=20?= =?UTF-8?q?`CommentController`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Последним шагом добавим `PrivateRouteMiddleware` в обработчике `create`. Теперь, маршрут для создания комментария стал приватным. К нему нельзя выполнить запрос без токена. --- src/shared/libs/rest/index.ts | 2 ++ src/shared/modules/comment/comment.controller.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/shared/libs/rest/index.ts b/src/shared/libs/rest/index.ts index f6ca54a..9f1da53 100644 --- a/src/shared/libs/rest/index.ts +++ b/src/shared/libs/rest/index.ts @@ -13,3 +13,5 @@ export * from './middleware/validate-objectid.middleware.js'; export * from './middleware/validate-dto.middleware.js'; export * from './middleware/document-exists.middleware.js'; export * from './middleware/upload-file.middleware.js'; +export * from './middleware/parse-token.middleware.js'; +export * from './middleware/private-route.middleware.js'; diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index 8a60f46..8efdcc3 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -1,7 +1,13 @@ import { inject, injectable } from 'inversify'; import { Response } from 'express'; import { StatusCodes } from 'http-status-codes'; -import { BaseController, HttpError, HttpMethod, ValidateDtoMiddleware } from '../../libs/rest/index.js'; +import { + BaseController, + HttpError, + HttpMethod, + PrivateRouteMiddleware, + ValidateDtoMiddleware, +} from '../../libs/rest/index.js'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { CommentService } from './comment-service.interface.js'; @@ -26,6 +32,7 @@ export default class CommentController extends BaseController { method: HttpMethod.Post, handler: this.create, middlewares: [ + new PrivateRouteMiddleware(), new ValidateDtoMiddleware(CreateCommentDto) ] }); From 347edbb264a7455a54e6613c6037c5502cadfad7 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:11:53 +0300 Subject: [PATCH 15/18] =?UTF-8?q?8.15.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit К обработчикам маршрутов, которые отвечают за создание данных подключим middleware `PrivateRouteMiddleware`. К этим ресурсам могут обращаться только авторизованные клиенты. --- src/shared/modules/category/category.controller.ts | 7 +++++-- src/shared/modules/offer/offer.controller.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/shared/modules/category/category.controller.ts b/src/shared/modules/category/category.controller.ts index 1fd1698..c71fcd5 100644 --- a/src/shared/modules/category/category.controller.ts +++ b/src/shared/modules/category/category.controller.ts @@ -3,7 +3,7 @@ import { Response, Request } from 'express'; import { BaseController, HttpError, - HttpMethod, + HttpMethod, PrivateRouteMiddleware, RequestQuery, ValidateDtoMiddleware, ValidateObjectIdMiddleware, } from '../../libs/rest/index.js'; @@ -33,7 +33,10 @@ export class CategoryController extends BaseController { path: '/', method: HttpMethod.Post, handler: this.create, - middlewares: [new ValidateDtoMiddleware(CreateCategoryDto)] + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateDtoMiddleware(CreateCategoryDto) + ] }); this.addRoute({ path: '/:categoryId/offers', diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index 994f23c..3563e85 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -1,6 +1,6 @@ import { BaseController, DocumentExistsMiddleware, - HttpMethod, + HttpMethod, PrivateRouteMiddleware, ValidateDtoMiddleware, ValidateObjectIdMiddleware, } from '../../libs/rest/index.js'; @@ -42,13 +42,17 @@ export default class OfferController extends BaseController { path: '/', method: HttpMethod.Post, handler: this.create, - middlewares: [new ValidateDtoMiddleware(CreateOfferDto)] + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateDtoMiddleware(CreateOfferDto) + ] }); this.addRoute({ path: '/:offerId', method: HttpMethod.Delete, handler: this.delete, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId') ] @@ -58,6 +62,7 @@ export default class OfferController extends BaseController { method: HttpMethod.Patch, handler: this.update, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new ValidateDtoMiddleware(UpdateOfferDto), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId') From 7bd1a3f9191518cd733ab5b783877f31a3c777b3 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:12:17 +0300 Subject: [PATCH 16/18] =?UTF-8?q?8.16.=20=D0=97=D0=B0=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=20`userId`=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После добавления ограничений для маршрутов внесём исправления в обработчик создания нового объявления. Вместо ожидания идентификатора автора объявления от клиента, подставим его самостоятельно. Затем откроем `CreateOfferDto` и уберём валидатор с `userId`. Больше он не потребуется. --- src/shared/modules/offer/dto/create-offer.dto.ts | 1 - src/shared/modules/offer/offer.controller.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index e53c85c..0fc885e 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -29,6 +29,5 @@ export class CreateOfferDto { @IsMongoId({ each: true, message: CreateOfferValidationMessage.categories.invalidId }) public categories: string[]; - @IsMongoId({ message: CreateOfferValidationMessage.userId.invalidId }) public userId: string; } diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index 3563e85..42d1c74 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -92,8 +92,8 @@ export default class OfferController extends BaseController { this.ok(res, fillDTO(OfferRdo, offers)); } - public async create({ body }: CreateOfferRequest, res: Response): Promise { - const result = await this.offerService.create(body); + public async create({ body, tokenPayload }: CreateOfferRequest, res: Response): Promise { + const result = await this.offerService.create({ ...body, userId: tokenPayload.id }); const offer = await this.offerService.findById(result.id); this.created(res, fillDTO(OfferRdo, offer)); } From 8a428e9e0b2cbe11d130a01111729b4945bab59f Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:12:43 +0300 Subject: [PATCH 17/18] =?UTF-8?q?8.17.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фронтенду необходима возможность проверять актуальность JWT-токена и если токен актуален, возвращать информацию о пользователе. Сделать такой ресурс несложно. Добавим обработчик для маршрута `GET /login` в `UserController`. --- src/shared/modules/user/user.controller.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index 311a3b4..13c4291 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -53,6 +53,11 @@ export class UserController extends BaseController { new UploadFileMiddleware(this.configService.get('UPLOAD_DIRECTORY'), 'avatar'), ] }); + this.addRoute({ + path: '/login', + method: HttpMethod.Get, + handler: this.checkAuthenticate, + }); } public async create( @@ -91,4 +96,18 @@ export class UserController extends BaseController { filepath: req.file?.path }); } + + public async checkAuthenticate({ tokenPayload: { email }}: Request, res: Response) { + const foundedUser = await this.userService.findByEmail(email); + + if (! foundedUser) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'UserController' + ); + } + + this.ok(res, fillDTO(LoggedUserRdo, foundedUser)); + } } From 0f8b6f1a90ab98c0b884b11899558b1c8c2f5205 Mon Sep 17 00:00:00 2001 From: Igor Antonov Date: Tue, 20 Feb 2024 19:12:52 +0300 Subject: [PATCH 18/18] =?UTF-8?q?8.18.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавим запрос для ресурса `GET /login`, чтобы клиент мог проверить токен на актуальность. --- src/shared/modules/user/user.http | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index bd6a061..e8c8ea4 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -38,4 +38,10 @@ Content-Type: image/png < /Users/spider_net/Desktop/screen.png ------WebKitFormBoundary7MA4YWxkTrZu0gW-- -## +### + +## Проверить токен пользователя +GET http://localhost:4000/users/login HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRvcmFuc0BvdmVybG9vay5uZXQiLCJpZCI6IjY0NzBiYzM4M2UzMGRiNjc5ZGZhYzJkMiIsImlhdCI6MTY4NTk3NTQ4MiwiZXhwIjoxNjg2MTQ4MjgyfQ.Gq2-B1egouAnMxmXlR2ElVT6wCa97PS6lxzVI8LnGvo + +###