From f2d5c712cc1c78a7408838dca74ed41fad73315b Mon Sep 17 00:00:00 2001 From: Karlen Date: Wed, 6 Nov 2024 18:21:09 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D0=BE=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D0=B8=D0=BC=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=B5=D1=89=D1=91=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 ++ package-lock.json | 14 +++++ package.json | 1 + src/main.rest.ts | 4 +- src/rest/rest.application.ts | 9 +++- src/shared/libs/config/rest.schema.ts | 7 +++ .../controller/base-controller.abstract.ts | 16 +++--- .../rest/controller/controller.interface.ts | 8 +-- .../exception-filter/app-exception-filter.ts | 31 ++++++++--- .../exception-filter.interface.ts | 2 +- .../middleware/document-exists.middleware.ts | 2 +- src/shared/libs/rest/middleware/index.ts | 2 + .../rest/middleware/middleware.interface.ts | 2 +- .../rest/middleware/parse-token.middleware.ts | 50 ++++++++++++++++++ .../middleware/private-route.middleware.ts | 19 +++++++ .../rest/middleware/upload-file.middleware.ts | 6 +-- .../middleware/validate-dto.middleware.ts | 4 +- .../validate-objectid.middleware.ts | 2 +- .../modules/auth/auth-service.interface.ts | 6 +++ src/shared/modules/auth/auth.container.ts | 18 +++++++ .../modules/auth/auth.exception-filter.ts | 25 +++++++++ .../modules/auth/default-auth.service.ts | 52 +++++++++++++++++++ .../auth/errors/base-user.exception.ts | 7 +++ src/shared/modules/auth/errors/index.ts | 3 ++ .../auth/errors/user-not-found.exception.ts | 8 +++ .../user-password-incorrect.exception.ts | 8 +++ src/shared/modules/auth/index.ts | 4 ++ src/shared/modules/auth/types/TokenPayload.ts | 5 ++ src/shared/modules/auth/types/index.ts | 1 + .../modules/category/category.controller.ts | 4 +- .../modules/comment/comment.controller.ts | 4 +- src/shared/modules/offer/offer.controller.ts | 24 ++++----- src/shared/modules/user/index.ts | 1 + .../modules/user/rdo/logged-user.rdo.ts | 9 ++++ src/shared/modules/user/user.controller.ts | 33 +++++------- src/shared/modules/user/user.entity.ts | 6 +++ src/shared/types/component.enum.ts | 3 ++ tsconfig.json | 13 +++-- types/custom.d.ts | 7 +++ 39 files changed, 349 insertions(+), 74 deletions(-) create mode 100644 src/shared/libs/rest/middleware/parse-token.middleware.ts create mode 100644 src/shared/libs/rest/middleware/private-route.middleware.ts create mode 100644 src/shared/modules/auth/auth-service.interface.ts create mode 100644 src/shared/modules/auth/auth.container.ts create mode 100644 src/shared/modules/auth/auth.exception-filter.ts create mode 100644 src/shared/modules/auth/default-auth.service.ts 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 create mode 100644 src/shared/modules/auth/index.ts create mode 100644 src/shared/modules/auth/types/TokenPayload.ts create mode 100644 src/shared/modules/auth/types/index.ts create mode 100644 src/shared/modules/user/rdo/logged-user.rdo.ts create mode 100644 types/custom.d.ts diff --git a/.env.example b/.env.example index e6e5236..252fb14 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,6 @@ DB_NAME=six-cities # UPLOAD UPLOAD_DIRECTORY=/home/node/app/upload + +# Auth +JWT_SECRET=secret diff --git a/package-lock.json b/package-lock.json index bba62bc..02e1a90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "got": "14.4.2", "http-status-codes": "2.3.0", "inversify": "6.0.2", + "jose": "5.9.6", "mime-types": "2.1.35", "mongodb": "6.9.0", "mongoose": "8.7.1", @@ -4395,6 +4396,14 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "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", @@ -10677,6 +10686,11 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==" + }, "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 cb28693..a6653f1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "got": "14.4.2", "http-status-codes": "2.3.0", "inversify": "6.0.2", + "jose": "5.9.6", "mime-types": "2.1.35", "mongodb": "6.9.0", "mongoose": "8.7.1", diff --git a/src/main.rest.ts b/src/main.rest.ts index 4dadf15..b9e9d78 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { Container } from 'inversify'; import { createRestApplicationContainer } from './rest/rest.container.js'; +import { createAuthContainer } from './shared/modules/auth/index.js'; import { createUserContainer } from './shared/modules/user/index.js'; import { createOfferContainer } from './shared/modules/offer/index.js'; import { createCommentContainer } from './shared/modules/comment/index.js'; @@ -15,7 +16,8 @@ async function bootstrap() { createUserContainer(), createOfferContainer(), createCommentContainer(), - createCategoryContainer() + createCategoryContainer(), + createAuthContainer() ); const application = container.get(Component.RestApplication); diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 5e0d10d..64160d0 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -1,7 +1,7 @@ import { inject, injectable } from 'inversify'; import express, { Express } from 'express'; -import { Controller, ExceptionFilter } from '../shared/libs/rest/index.js'; +import { Controller, ExceptionFilter, ParseTokenMiddleware } from '../shared/libs/rest/index.js'; import { DatabaseClient } from '../shared/libs/database-client/index.js'; import { Config, RestSchema } from '../shared/libs/config/index.js'; import { Logger } from '../shared/libs/logger/index.js'; @@ -18,12 +18,14 @@ export class RestApplication { @inject(Component.CommentController) @inject(Component.CategoryController) @inject(Component.ExceptionFilter) + @inject(Component.AuthExceptionFilter) private readonly databaseClient: DatabaseClient, private readonly userController: Controller, private readonly offerController: Controller, private readonly commentController: Controller, private readonly categoryController: Controller, - private readonly appExceptionFilter: ExceptionFilter + private readonly appExceptionFilter: ExceptionFilter, + private readonly authExceptionFilter: ExceptionFilter ) { this.server = express(); } @@ -50,14 +52,17 @@ export class RestApplication { private initMiddleware() { this.logger.info('Инициализация middleware-ов'); + 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)); this.logger.info('Инициализация middleware-ов завершена'); } private initExceptionFilters() { this.logger.info('Инициализация фильтров исключений'); this.server.use(this.appExceptionFilter.catch.bind(this.appExceptionFilter)); + this.server.use(this.authExceptionFilter.catch.bind(this.authExceptionFilter)); this.logger.info('Инициализация фильтров исключений завершена'); } diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index 05bb97c..88bc9a4 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, + }, }); diff --git a/src/shared/libs/rest/controller/base-controller.abstract.ts b/src/shared/libs/rest/controller/base-controller.abstract.ts index 4a73b39..8501f07 100644 --- a/src/shared/libs/rest/controller/base-controller.abstract.ts +++ b/src/shared/libs/rest/controller/base-controller.abstract.ts @@ -38,19 +38,19 @@ export abstract class BaseController implements Controller { this.logger.info(`Маршрут зарегистрирован: ${route.method.toUpperCase()} ${route.path}`); } - public send(res: Response, statusCode: number, data: T) { - res.type(this.DEFAULT_CONTENT_TYPE).status(statusCode).json(data); + public send(response: Response, statusCode: number, data: T) { + response.type(this.DEFAULT_CONTENT_TYPE).status(statusCode).json(data); } - public created(res: Response, data: T): void { - this.send(res, StatusCodes.CREATED, data); + public created(response: Response, data: T): void { + this.send(response, StatusCodes.CREATED, data); } - public noContent(res: Response, data: T): void { - this.send(res, StatusCodes.NO_CONTENT, data); + public noContent(response: Response, data: T): void { + this.send(response, StatusCodes.NO_CONTENT, data); } - public ok(res: Response, data: T): void { - this.send(res, StatusCodes.OK, data); + public ok(response: Response, data: T): void { + this.send(response, StatusCodes.OK, data); } } diff --git a/src/shared/libs/rest/controller/controller.interface.ts b/src/shared/libs/rest/controller/controller.interface.ts index ae72232..0c6ca05 100644 --- a/src/shared/libs/rest/controller/controller.interface.ts +++ b/src/shared/libs/rest/controller/controller.interface.ts @@ -4,8 +4,8 @@ import { Route } from '../types/index.js'; export interface Controller { readonly router: Router; addRoute(route: Route): void; - send(res: Response, statusCode: number, data: T): void; - ok(res: Response, data: T): void; - created(res: Response, data: T): void; - noContent(res: Response, data: T): void; + send(response: Response, statusCode: number, data: T): void; + ok(response: Response, data: T): void; + created(response: Response, data: T): void; + noContent(response: Response, data: T): void; } diff --git a/src/shared/libs/rest/exception-filter/app-exception-filter.ts b/src/shared/libs/rest/exception-filter/app-exception-filter.ts index 8c150be..6bc99b0 100644 --- a/src/shared/libs/rest/exception-filter/app-exception-filter.ts +++ b/src/shared/libs/rest/exception-filter/app-exception-filter.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction } from 'express'; +import { NextFunction, Request, Response } from 'express'; import { inject, injectable } from 'inversify'; import { ExceptionFilter } from './exception-filter.interface.js'; @@ -13,22 +13,37 @@ export class AppExceptionFilter implements ExceptionFilter { this.logger.info('Register AppExceptionFilter'); } - private handleHttpError(error: HttpError, _req: Request, res: Response, _next: NextFunction) { + private handleHttpError( + error: HttpError, + _request: Request, + response: Response, + _next: NextFunction + ) { this.logger.error(`[${error.detail}]: ${error.httpStatusCode} — ${error.message}`, error); - res.status(error.httpStatusCode).json(new Error(error.message)); + response.status(error.httpStatusCode).json(new Error(error.message)); } - private handleOtherError(error: Error, _req: Request, res: Response, _next: NextFunction) { + private handleOtherError( + error: Error, + _request: Request, + response: Response, + _next: NextFunction + ) { this.logger.error(error); - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json(new Error(error.message)); + response.status(StatusCodes.INTERNAL_SERVER_ERROR).json(new Error(error.message)); } - public catch(error: Error | HttpError, req: Request, res: Response, next: NextFunction): void { + public catch( + error: Error | HttpError, + request: Request, + response: Response, + next: NextFunction + ): void { if (error instanceof HttpError) { - return this.handleHttpError(error, req, res, next); + return this.handleHttpError(error, request, response, next); } - this.handleOtherError(error, req, res, next); + this.handleOtherError(error, request, response, next); } } diff --git a/src/shared/libs/rest/exception-filter/exception-filter.interface.ts b/src/shared/libs/rest/exception-filter/exception-filter.interface.ts index 64b4b95..219733c 100644 --- a/src/shared/libs/rest/exception-filter/exception-filter.interface.ts +++ b/src/shared/libs/rest/exception-filter/exception-filter.interface.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from 'express'; export interface ExceptionFilter { - catch(error: Error, req: Request, res: Response, next: NextFunction): void; + catch(error: Error, request: Request, response: Response, next: NextFunction): void; } diff --git a/src/shared/libs/rest/middleware/document-exists.middleware.ts b/src/shared/libs/rest/middleware/document-exists.middleware.ts index 493fc5d..79f65f8 100644 --- a/src/shared/libs/rest/middleware/document-exists.middleware.ts +++ b/src/shared/libs/rest/middleware/document-exists.middleware.ts @@ -12,7 +12,7 @@ export class DocumentExistsMiddleware implements Middleware { private readonly paramName: string ) {} - public async execute({ params }: Request, _res: Response, next: NextFunction): Promise { + public async execute({ params }: Request, _response: Response, next: NextFunction): Promise { const documentId = params[this.paramName]; const exists = await this.service.exists(documentId); diff --git a/src/shared/libs/rest/middleware/index.ts b/src/shared/libs/rest/middleware/index.ts index 3f5250a..b07d4a9 100644 --- a/src/shared/libs/rest/middleware/index.ts +++ b/src/shared/libs/rest/middleware/index.ts @@ -3,3 +3,5 @@ export * from './validate-dto.middleware.js'; export * from './validate-objectid.middleware.js'; export * from './document-exists.middleware.js'; export * from './upload-file.middleware.js'; +export * from './parse-token.middleware.js'; +export * from './private-route.middleware.js'; diff --git a/src/shared/libs/rest/middleware/middleware.interface.ts b/src/shared/libs/rest/middleware/middleware.interface.ts index e28091a..63adaee 100644 --- a/src/shared/libs/rest/middleware/middleware.interface.ts +++ b/src/shared/libs/rest/middleware/middleware.interface.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from 'express'; export interface Middleware { - execute(req: Request, res: Response, next: NextFunction): void; + execute(request: Request, response: Response, next: NextFunction): void; } 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..5dd4420 --- /dev/null +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -0,0 +1,50 @@ +import { NextFunction, Request, Response } from 'express'; +import { jwtVerify } from 'jose'; +import { StatusCodes } from 'http-status-codes'; +import { createSecretKey } from 'node:crypto'; +import { ENCODING } from '../../../../constants/index.js'; + +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' && + 'name' in payload && + typeof payload.name === 'string' && + 'id' in payload && + typeof payload.id === 'string' + ); +} + +export class ParseTokenMiddleware implements Middleware { + constructor(private readonly jwtSecret: string) {} + + public async execute(request: Request, _response: Response, next: NextFunction): Promise { + const authorizationHeader = request.headers?.authorization?.split(' '); + if (!authorizationHeader) { + return next(); + } + + const [, token] = authorizationHeader; + + try { + const { payload } = await jwtVerify(token, createSecretKey(this.jwtSecret, ENCODING)); + + if (isTokenPayload(payload)) { + request.tokenPayload = { ...payload }; + return next(); + } else { + throw new Error('Не корректный токен'); + } + } catch { + return next( + new HttpError(StatusCodes.UNAUTHORIZED, 'Неверный токен', 'AuthenticateMiddleware') + ); + } + } +} 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..867d11a --- /dev/null +++ b/src/shared/libs/rest/middleware/private-route.middleware.ts @@ -0,0 +1,19 @@ +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, + _response: Response, + next: NextFunction + ): Promise { + if (!tokenPayload) { + throw new HttpError(StatusCodes.UNAUTHORIZED, 'Unauthorized', 'PrivateRouteMiddleware'); + } + + return next(); + } +} diff --git a/src/shared/libs/rest/middleware/upload-file.middleware.ts b/src/shared/libs/rest/middleware/upload-file.middleware.ts index ea6ad88..e298cdb 100644 --- a/src/shared/libs/rest/middleware/upload-file.middleware.ts +++ b/src/shared/libs/rest/middleware/upload-file.middleware.ts @@ -11,10 +11,10 @@ export class UploadFileMiddleware implements Middleware { private fieldName: string ) {} - public async execute(req: Request, res: Response, next: NextFunction): Promise { + public async execute(request: Request, response: Response, next: NextFunction): Promise { const storage = diskStorage({ destination: this.uploadDirectory, - filename: (_req, file, callback) => { + filename: (_request, file, callback) => { const fileExtension = extension(file.mimetype); const filename = randomUUID(); callback(null, `${filename}.${fileExtension}`); @@ -23,6 +23,6 @@ export class UploadFileMiddleware implements Middleware { const uploadSingleFileMiddleware = multer({ storage }).single(this.fieldName); - uploadSingleFileMiddleware(req, res, next); + uploadSingleFileMiddleware(request, response, next); } } diff --git a/src/shared/libs/rest/middleware/validate-dto.middleware.ts b/src/shared/libs/rest/middleware/validate-dto.middleware.ts index 42f5613..38aa465 100644 --- a/src/shared/libs/rest/middleware/validate-dto.middleware.ts +++ b/src/shared/libs/rest/middleware/validate-dto.middleware.ts @@ -8,12 +8,12 @@ import { Middleware } from './middleware.interface.js'; export class ValidateDtoMiddleware implements Middleware { constructor(private dto: ClassConstructor) {} - public async execute({ body }: Request, res: Response, next: NextFunction): Promise { + public async execute({ body }: Request, response: Response, next: NextFunction): Promise { const dtoInstance = plainToInstance(this.dto, body); const errors = await validate(dtoInstance); if (errors.length > 0) { - res.status(StatusCodes.BAD_REQUEST).send(errors); + response.status(StatusCodes.BAD_REQUEST).send(errors); return; } diff --git a/src/shared/libs/rest/middleware/validate-objectid.middleware.ts b/src/shared/libs/rest/middleware/validate-objectid.middleware.ts index c85b89f..2ca8c57 100644 --- a/src/shared/libs/rest/middleware/validate-objectid.middleware.ts +++ b/src/shared/libs/rest/middleware/validate-objectid.middleware.ts @@ -8,7 +8,7 @@ import { HttpError } from '../errors/index.js'; export class ValidateObjectIdMiddleware implements Middleware { constructor(private param: string) {} - public execute({ params }: Request, _res: Response, next: NextFunction): void { + public execute({ params }: Request, _response: Response, next: NextFunction): void { const objectId = params[this.param]; if (Types.ObjectId.isValid(objectId)) { 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.container.ts b/src/shared/modules/auth/auth.container.ts new file mode 100644 index 0000000..ed9a7cc --- /dev/null +++ b/src/shared/modules/auth/auth.container.ts @@ -0,0 +1,18 @@ +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/auth.exception-filter.ts b/src/shared/modules/auth/auth.exception-filter.ts new file mode 100644 index 0000000..a3c1a4a --- /dev/null +++ b/src/shared/modules/auth/auth.exception-filter.ts @@ -0,0 +1,25 @@ +import { inject, injectable } from 'inversify'; +import { ExceptionFilter } from '../../libs/rest/index.js'; +import { Component } from '../../types/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { NextFunction, Request, Response } from 'express'; +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: Error, _request: Request, response: Response, next: NextFunction): void { + if (!(error instanceof BaseUserException)) { + return next(error); + } + + this.logger.error(`[AuthModule] ${error.message}`, error); + response.status(error.httpStatusCode).json({ + type: 'AUTHORIZATION', + error: error.message, + }); + } +} 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..5a69f16 --- /dev/null +++ b/src/shared/modules/auth/default-auth.service.ts @@ -0,0 +1,52 @@ +import { SignJWT } from 'jose'; +import { inject, injectable } from 'inversify'; +import { createSecretKey } from 'node:crypto'; +import { Component } from '../../types/index.js'; +import { ENCODING } from '../../../constants/index.js'; + +import { UserNotFoundException, UserPasswordIncorrectException } from './errors/index.js'; +import { LoginUserDto, UserEntity, UserService } from '../user/index.js'; +import { Config, RestSchema } from '../../libs/config/index.js'; +import { AuthService } from './auth-service.interface.js'; +import { Logger } from '../../libs/logger/index.js'; +import { TokenPayload } from './types/index.js'; + +export const JWT_ALGORITHM = 'HS256'; +export const JWT_EXPIRED = '2d'; + +@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 = createSecretKey(jwtSecret, ENCODING); + const tokenPayload: TokenPayload = { email: user.email, name: user.name, id: user.id }; + + this.logger.info(`Создание токена для ${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(`Пользователь с ${dto.email} не найден`); + throw new UserNotFoundException(); + } + + if (!user.verifyPassword(dto.password, this.config.get('SALT'))) { + this.logger.warn(`Неправильный пароль для ${dto.email}`); + throw new UserPasswordIncorrectException(); + } + + return user; + } +} 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'); + } +} diff --git a/src/shared/modules/auth/index.ts b/src/shared/modules/auth/index.ts new file mode 100644 index 0000000..882670c --- /dev/null +++ b/src/shared/modules/auth/index.ts @@ -0,0 +1,4 @@ +export * from './auth-service.interface.js'; +export * from './default-auth.service.js'; +export * from './auth.container.js'; +export * from './types/index.js'; diff --git a/src/shared/modules/auth/types/TokenPayload.ts b/src/shared/modules/auth/types/TokenPayload.ts new file mode 100644 index 0000000..648388e --- /dev/null +++ b/src/shared/modules/auth/types/TokenPayload.ts @@ -0,0 +1,5 @@ +export type TokenPayload = { + email: string; + name: string; + id: string; +}; diff --git a/src/shared/modules/auth/types/index.ts b/src/shared/modules/auth/types/index.ts new file mode 100644 index 0000000..63856ed --- /dev/null +++ b/src/shared/modules/auth/types/index.ts @@ -0,0 +1 @@ +export * from './TokenPayload.js'; diff --git a/src/shared/modules/category/category.controller.ts b/src/shared/modules/category/category.controller.ts index dd08dc8..916e614 100644 --- a/src/shared/modules/category/category.controller.ts +++ b/src/shared/modules/category/category.controller.ts @@ -23,7 +23,7 @@ export class CategoryController extends BaseController { this.addRoute({ path: '/id/:id', method: HttpMethod.get, handler: this.findById }); } - public async findById({ params }: Request, res: Response) { + public async findById({ params }: Request, response: Response) { const offer = await this.categoryService.findByCategoryId(params.id); if (!offer) { @@ -34,6 +34,6 @@ export class CategoryController extends BaseController { ); } - this.ok(res, fillDTO(CategoryRdo, offer)); + this.ok(response, fillDTO(CategoryRdo, offer)); } } diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index e3b5eeb..4f732d3 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -23,7 +23,7 @@ export class CommentController extends BaseController { this.addRoute({ path: '/id/:id', method: HttpMethod.get, handler: this.findById }); } - public async findById({ params }: Request, res: Response) { + public async findById({ params }: Request, response: Response) { const offer = await this.commentService.findByOfferId(params.id); if (!offer) { @@ -34,6 +34,6 @@ export class CommentController extends BaseController { ); } - this.ok(res, fillDTO(CommentRdo, offer)); + this.ok(response, fillDTO(CommentRdo, offer)); } } diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index c8031b2..6e77d3e 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -67,46 +67,46 @@ export class OfferController extends BaseController { ]); } - public async show({ params }: Request, res: Response) { + public async show({ params }: Request, response: Response) { const { offerId } = params; const offer = await this.offerService.findById(offerId); - this.ok(res, fillDTO(OfferRdo, offer)); + this.ok(response, fillDTO(OfferRdo, offer)); } - public async index(req: AllOffersRequest, res: Response) { + public async index(request: AllOffersRequest, response: Response) { let count: number | undefined = undefined; - if (req.query.count !== undefined) { - count = +req.query.count; + if (request.query.count !== undefined) { + count = +request.query.count; } const offers = await this.offerService.find(count); - this.ok(res, fillDTO(OfferRdo, offers)); + this.ok(response, fillDTO(OfferRdo, offers)); } - public async create({ body }: CreateOfferRequest, res: Response) { + public async create({ body }: CreateOfferRequest, response: Response) { const result = await this.offerService.create(body); const offer = await this.offerService.findById(result.id); - this.created(res, fillDTO(OfferRdo, offer)); + this.created(response, fillDTO(OfferRdo, offer)); } - public async delete({ params }: Request, res: Response): Promise { + public async delete({ params }: Request, response: Response): Promise { const { offerId } = params; const offer = await this.offerService.deleteById(offerId); - this.noContent(res, offer); + this.noContent(response, offer); } public async update( { body, params }: Request, - res: Response + response: Response ): Promise { const result = await this.offerService.updateById(params.offerId, body); const updatedOffer = await this.offerService.findById(result?.id); - this.ok(res, fillDTO(OfferRdo, updatedOffer)); + this.ok(response, fillDTO(OfferRdo, updatedOffer)); } public async getPremiumOfferByCity() {} diff --git a/src/shared/modules/user/index.ts b/src/shared/modules/user/index.ts index a785f4a..84be06b 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -6,4 +6,5 @@ export * from './user.container.js'; export * from './dto/create-user.dto.js'; export * from './dto/update-user.dto.js'; +export * from './dto/login-user.dto.js'; export * from './rdo/user.rdo.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 2c122cc..d80f192 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -5,9 +5,9 @@ import { Component } from '../../types/index.js'; import { fillDTO } from '../../helpers/common.js'; import { + BaseController, HttpError, HttpMethod, - BaseController, UploadFileMiddleware, ValidateDtoMiddleware, ValidateObjectIdMiddleware, @@ -15,19 +15,21 @@ import { import { CreateUserRequest } from './type/create-user-request.type.js'; import { LoginUserRequest } from './type/login-user-request.type.js'; import { Config, RestSchema } from '../../libs/config/index.js'; +import { AuthService } from '../auth/auth-service.interface.js'; import { UserService } from './user-service.interface.js'; -import { Logger } from '../../libs/logger/index.js'; -import { UserRdo } from './rdo/user.rdo.js'; import { CreateUserDto } from './dto/create-user.dto.js'; +import { LoggedUserRdo } from './rdo/logged-user.rdo.js'; import { LoginUserDto } from './dto/login-user.dto.js'; +import { Logger } from '../../libs/logger/index.js'; +import { UserRdo } from './rdo/user.rdo.js'; @injectable() export class UserController extends BaseController { constructor( + @inject(Component.Config) private readonly config: Config, @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); @@ -52,7 +54,7 @@ export class UserController extends BaseController { handler: this.uploadAvatar, middlewares: [ new ValidateObjectIdMiddleware('userId'), - new UploadFileMiddleware(this.configService.get('UPLOAD_DIRECTORY'), 'avatar'), + new UploadFileMiddleware(this.config.get('UPLOAD_DIRECTORY'), 'avatar'), ], }, ]); @@ -69,23 +71,16 @@ export class UserController extends BaseController { ); } - const result = await this.userService.create(body, this.configService.get('SALT')); + const result = await this.userService.create(body, this.config.get('SALT')); this.created(response, fillDTO(UserRdo, result)); } - public async login({ body }: LoginUserRequest, _response: Response) { - const user = await this.userService.findByEmail(body.email); - - if (!user) { - throw new HttpError( - StatusCodes.UNAUTHORIZED, - `Пользователь с электронной почтой ${body.email} не найдено.`, - 'UserController' - ); - } - - throw new HttpError(StatusCodes.NOT_IMPLEMENTED, 'Не реализовано', 'UserController'); + public async login({ body }: LoginUserRequest, res: Response) { + 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(request: Request, response: Response) { diff --git a/src/shared/modules/user/user.entity.ts b/src/shared/modules/user/user.entity.ts index 6f36612..cc1dd20 100644 --- a/src/shared/modules/user/user.entity.ts +++ b/src/shared/modules/user/user.entity.ts @@ -39,6 +39,12 @@ 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); diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index 3a94ec3..f086333 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -20,5 +20,8 @@ export const Component = { CategoryService: Symbol.for('CategoryService'), CategoryController: Symbol.for('CategoryController'), + AuthService: Symbol.for('AuthService'), + AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), + ExceptionFilter: Symbol.for('ExceptionFilter'), } as const; diff --git a/tsconfig.json b/tsconfig.json index e65f650..bf6887c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "ts-node": { "esm": true }, "compilerOptions": { "target": "ES2022", "module": "NodeNext", @@ -22,15 +23,13 @@ "outDir": "./dist", "emitDecoratorMetadata": true, "baseUrl": "./", - "types": [ - "node" - ], - "lib": [ - "ESNext" - ] + "types": ["node"], + "typeRoots": ["node_modules/@types", "types"], + "lib": ["ESNext"] }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "types/*.d.ts" ], "exclude": [ "node_modules", diff --git a/types/custom.d.ts b/types/custom.d.ts new file mode 100644 index 0000000..778a665 --- /dev/null +++ b/types/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; + } +} From 0b3f30963dc7bf28283113e254bcbe2d4aab9ca5 Mon Sep 17 00:00:00 2001 From: Karlen Date: Wed, 6 Nov 2024 18:24:38 +0300 Subject: [PATCH 2/2] Code review --- .../libs/rest/middleware/document-exists.middleware.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/libs/rest/middleware/document-exists.middleware.ts b/src/shared/libs/rest/middleware/document-exists.middleware.ts index 79f65f8..89ee3ae 100644 --- a/src/shared/libs/rest/middleware/document-exists.middleware.ts +++ b/src/shared/libs/rest/middleware/document-exists.middleware.ts @@ -12,7 +12,11 @@ export class DocumentExistsMiddleware implements Middleware { private readonly paramName: string ) {} - public async execute({ params }: Request, _response: Response, next: NextFunction): Promise { + public async execute( + { params }: Request, + _response: Response, + next: NextFunction + ): Promise { const documentId = params[this.paramName]; const exists = await this.service.exists(documentId);