From ab6a2c922346783856fa1a075a72b7c579f38dfa Mon Sep 17 00:00:00 2001 From: rcole1919 Date: Fri, 1 Nov 2024 23:39:26 +0400 Subject: [PATCH 1/5] add auth service --- .env.example | 1 + package-lock.json | 14 +++++ package.json | 1 + src/main.rest.ts | 2 + src/rest/rest.application.ts | 2 + src/shared/constants/auth.ts | 2 + src/shared/constants/component.ts | 2 + src/shared/constants/index.ts | 5 ++ src/shared/libs/config/rest.schema.ts | 8 ++- .../libs/config/types/rest-schema.type.ts | 1 + .../modules/auth/auth-exception-filter.ts | 30 +++++++++++ src/shared/modules/auth/auth.container.ts | 14 +++++ .../modules/auth/default-auth.service.ts | 53 +++++++++++++++++++ .../auth/errors/base-user.exception.ts | 7 +++ src/shared/modules/auth/errors/index.ts | 3 ++ .../auth/errors/user-not-found.exception.ts | 9 ++++ .../user-password-incorrect.exception.ts | 9 ++++ src/shared/modules/auth/index.ts | 3 ++ .../auth/types/auth-service.interface.ts | 7 +++ src/shared/modules/auth/types/index.ts | 2 + .../modules/auth/types/token-payload.type.ts | 5 ++ src/shared/modules/user/user.entity.ts | 5 ++ 22 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/shared/constants/auth.ts create mode 100644 src/shared/modules/auth/auth-exception-filter.ts create mode 100644 src/shared/modules/auth/auth.container.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/auth-service.interface.ts create mode 100644 src/shared/modules/auth/types/index.ts create mode 100644 src/shared/modules/auth/types/token-payload.type.ts diff --git a/.env.example b/.env.example index c15b20e..c1bb330 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,4 @@ DB_PORT= DB_UI_PORT= DB_NAME= UPLOAD_DIRECTORY= +JWT_SECRET= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ab9395e..efc2386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "got": "14.4.2", "http-status-codes": "2.3.0", "inversify": "6.0.2", + "jose": "^5.9.6", "mime-types": "2.1.35", "mongoose": "8.3.4", "multer": "1.4.5-lts.1", @@ -4317,6 +4318,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", @@ -10519,6 +10528,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 c2d4a71..4bc98ac 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "got": "14.4.2", "http-status-codes": "2.3.0", "inversify": "6.0.2", + "jose": "^5.9.6", "mime-types": "2.1.35", "mongoose": "8.3.4", "multer": "1.4.5-lts.1", diff --git a/src/main.rest.ts b/src/main.rest.ts index a485932..f067a68 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -6,6 +6,7 @@ import { COMPONENT } from './shared/constants/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'; +import { createAuthContainer } from './shared/modules/auth/index.js'; async function bootstrap() { const appContainer = Container.merge( @@ -13,6 +14,7 @@ async function bootstrap() { createUserContainer(), createOfferContainer(), createCommentContainer(), + createAuthContainer(), ); const application = appContainer.get(COMPONENT.REST_APPLICATION); diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 932b063..e8d4d14 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -20,6 +20,7 @@ export class RestApplication { @inject(COMPONENT.USER_CONTROLLER) private readonly userController: IController, @inject(COMPONENT.COMMENT_CONTROLLER) private readonly commentController: IController, @inject(COMPONENT.EXCEPTION_FILTER) private readonly appExceptionFilter: IExceptionFilter, + @inject(COMPONENT.AUTH_EXCEPTION_FILTER) private readonly authExceptionFilter: IExceptionFilter, ) {} private async initDb() { @@ -54,6 +55,7 @@ export class RestApplication { } private initExceptionFilters() { + this.server.use(this.authExceptionFilter.catch.bind(this.authExceptionFilter)); this.server.use(this.appExceptionFilter.catch.bind(this.appExceptionFilter)); } diff --git a/src/shared/constants/auth.ts b/src/shared/constants/auth.ts new file mode 100644 index 0000000..269747d --- /dev/null +++ b/src/shared/constants/auth.ts @@ -0,0 +1,2 @@ +export const JWT_ALGORITHM = 'HS256'; +export const JWT_EXPIRED = '2d'; diff --git a/src/shared/constants/component.ts b/src/shared/constants/component.ts index f64aa52..fbf3c3d 100644 --- a/src/shared/constants/component.ts +++ b/src/shared/constants/component.ts @@ -13,4 +13,6 @@ export const COMPONENT = { EXCEPTION_FILTER: Symbol(), USER_CONTROLLER: Symbol(), COMMENT_CONTROLLER: Symbol(), + AUTH_SERVICE: Symbol(), + AUTH_EXCEPTION_FILTER: Symbol(), }; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index b02cb95..fb427ae 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -35,3 +35,8 @@ export { DB_CONNECT_RETRY, IMAGE_EXTENSIONS, } from './common.js'; + +export { + JWT_ALGORITHM, + JWT_EXPIRED, +} from './auth.js'; diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index 7ba4ee9..24ad2c6 100644 --- a/src/shared/libs/config/rest.schema.ts +++ b/src/shared/libs/config/rest.schema.ts @@ -65,5 +65,11 @@ export const configRestSchema = convict({ format: String, 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/config/types/rest-schema.type.ts b/src/shared/libs/config/types/rest-schema.type.ts index 965fb3e..c585fd6 100644 --- a/src/shared/libs/config/types/rest-schema.type.ts +++ b/src/shared/libs/config/types/rest-schema.type.ts @@ -9,4 +9,5 @@ export type TRestSchema = { DB_UI_PORT: string; DB_NAME: string; UPLOAD_DIRECTORY: string; + JWT_SECRET: string; } 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..a6d9176 --- /dev/null +++ b/src/shared/modules/auth/auth-exception-filter.ts @@ -0,0 +1,30 @@ +import { inject, injectable } from 'inversify'; +import { NextFunction, Request, Response } from 'express'; + +import { IExceptionFilter } from '../../../rest/types/index.js'; +import { COMPONENT } from '../../constants/index.js'; +import { ILogger } from '../../libs/logger/types/index.js'; +import { BaseUserException } from './errors/index.js'; + +@injectable() +export class AuthExceptionFilter implements IExceptionFilter { + constructor( + @inject(COMPONENT.LOGGER) private readonly logger: ILogger, + ) { + 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, + }); + } +} diff --git a/src/shared/modules/auth/auth.container.ts b/src/shared/modules/auth/auth.container.ts new file mode 100644 index 0000000..e818027 --- /dev/null +++ b/src/shared/modules/auth/auth.container.ts @@ -0,0 +1,14 @@ +import { Container } from 'inversify'; + +import { IAuthService } from './types/index.js'; +import { COMPONENT } from '../../constants/index.js'; +import { DefualtAuthService, AuthExceptionFilter } from './index.js'; +import { IExceptionFilter } from '../../../rest/types/index.js'; + +export function createAuthContainer() { + const authContainer = new Container(); + authContainer.bind(COMPONENT.AUTH_SERVICE).to(DefualtAuthService).inSingletonScope(); + authContainer.bind(COMPONENT.AUTH_EXCEPTION_FILTER).to(AuthExceptionFilter).inSingletonScope(); + + return authContainer; +} 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..edc44f4 --- /dev/null +++ b/src/shared/modules/auth/default-auth.service.ts @@ -0,0 +1,53 @@ +import { inject, injectable } from 'inversify'; +import * as crypto from 'node:crypto'; +import { SignJWT } from 'jose'; + +import { IAuthService, TTokenPayload } from './types/index.js'; +import { COMPONENT, JWT_ALGORITHM, JWT_EXPIRED } from '../../constants/index.js'; +import { ILogger } from '../../libs/logger/types/index.js'; +import { LoginUserDTO } from '../user/index.js'; +import { IUserService } from '../user/types/index.js'; +import { UserEntity } from '../user/user.entity.js'; +import { IConfig, TRestSchema } from '../../libs/config/types/index.js'; +import { UserNotFoundException, UserPasswordIncorrectException } from './errors/index.js'; + +@injectable() +export class DefualtAuthService implements IAuthService { + constructor( + @inject(COMPONENT.LOGGER) private readonly logger: ILogger, + @inject(COMPONENT.USER_SERVICE) private readonly userService: IUserService, + @inject(COMPONENT.CONFIG) private readonly config: IConfig, + ) {} + + public async authenticate(user: UserEntity): Promise { + const jwtSecret = this.config.get('JWT_SECRET'); + const secretKey = crypto.createSecretKey(jwtSecret, 'utf-8'); + const tokenPayload: TTokenPayload = { + email: user.email, + name: user.name, + 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 verfy(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/errors/base-user.exception.ts b/src/shared/modules/auth/errors/base-user.exception.ts new file mode 100644 index 0000000..89e4cf9 --- /dev/null +++ b/src/shared/modules/auth/errors/base-user.exception.ts @@ -0,0 +1,7 @@ +import { HttpError } from '../../../../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..c002b50 --- /dev/null +++ b/src/shared/modules/auth/errors/index.ts @@ -0,0 +1,3 @@ +export { BaseUserException } from './base-user.exception.js'; +export { UserNotFoundException } from './user-not-found.exception.js'; +export { UserPasswordIncorrectException } 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..9387b5f --- /dev/null +++ b/src/shared/modules/auth/errors/user-not-found.exception.ts @@ -0,0 +1,9 @@ +import { StatusCodes } from 'http-status-codes'; + +import { BaseUserException } from './index.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..d95b83a --- /dev/null +++ b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts @@ -0,0 +1,9 @@ +import { StatusCodes } from 'http-status-codes'; + +import { BaseUserException } from './index.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..1fa8a2e --- /dev/null +++ b/src/shared/modules/auth/index.ts @@ -0,0 +1,3 @@ +export { AuthExceptionFilter } from './auth-exception-filter.js'; +export { DefualtAuthService } from './default-auth.service.js'; +export { createAuthContainer } from './auth.container.js'; diff --git a/src/shared/modules/auth/types/auth-service.interface.ts b/src/shared/modules/auth/types/auth-service.interface.ts new file mode 100644 index 0000000..41d537a --- /dev/null +++ b/src/shared/modules/auth/types/auth-service.interface.ts @@ -0,0 +1,7 @@ +import { LoginUserDTO } from '../../user/index.js'; +import { UserEntity } from '../../user/user.entity.js'; + +export interface IAuthService { + authenticate(user: UserEntity): Promise; + verfy(dto: LoginUserDTO): Promise; +} diff --git a/src/shared/modules/auth/types/index.ts b/src/shared/modules/auth/types/index.ts new file mode 100644 index 0000000..7dda4d0 --- /dev/null +++ b/src/shared/modules/auth/types/index.ts @@ -0,0 +1,2 @@ +export { IAuthService } from './auth-service.interface.js'; +export { TTokenPayload } from './token-payload.type.js'; diff --git a/src/shared/modules/auth/types/token-payload.type.ts b/src/shared/modules/auth/types/token-payload.type.ts new file mode 100644 index 0000000..7db9fe5 --- /dev/null +++ b/src/shared/modules/auth/types/token-payload.type.ts @@ -0,0 +1,5 @@ +export type TTokenPayload = { + email: string; + name: string; + id: string; +} diff --git a/src/shared/modules/user/user.entity.ts b/src/shared/modules/user/user.entity.ts index b4076b9..de1d3f0 100644 --- a/src/shared/modules/user/user.entity.ts +++ b/src/shared/modules/user/user.entity.ts @@ -62,4 +62,9 @@ export class UserEntity extends defaultClasses.TimeStamps implements IUser { public setPassword(password: string, salt: string) { this.password = createSHA256(password, salt); } + + public verifyPassword(password: string, salt: string) { + const hashPassword = createSHA256(password, salt); + return hashPassword === this.password; + } } From ba1087146696b589218a137886b149e9ae981ac9 Mon Sep 17 00:00:00 2001 From: rcole1919 Date: Sat, 2 Nov 2024 01:37:54 +0400 Subject: [PATCH 2/5] add token middlewares --- custom.d.ts | 7 +++ src/rest/index.ts | 2 + .../middlewares/parse-token.middleware.ts | 40 ++++++++++++++++ .../middlewares/private-route.middleware.ts | 19 ++++++++ src/rest/rest.application.ts | 3 ++ src/rest/types/middleware.interface.ts | 2 +- src/shared/helpers/index.ts | 1 + src/shared/helpers/token.ts | 8 ++++ .../modules/auth/default-auth.service.ts | 8 +++- .../auth/types/auth-service.interface.ts | 2 +- .../modules/comment/comment.controller.ts | 6 ++- src/shared/modules/comment/comment.http | 2 +- .../modules/comment/dto/create-comment.dto.ts | 3 +- .../modules/offer/dto/create-offer.dto.ts | 2 - src/shared/modules/offer/offer.controller.ts | 12 +++-- .../modules/user/default-user.service.ts | 8 +--- src/shared/modules/user/index.ts | 1 + .../modules/user/rdo/logged-user.rdo.ts | 9 ++++ src/shared/modules/user/user.controller.ts | 46 ++++++++++++------- src/shared/modules/user/user.http | 16 +++++-- tsconfig.json | 8 +++- 21 files changed, 161 insertions(+), 44 deletions(-) create mode 100644 custom.d.ts create mode 100644 src/rest/middlewares/parse-token.middleware.ts create mode 100644 src/rest/middlewares/private-route.middleware.ts create mode 100644 src/shared/helpers/token.ts create mode 100644 src/shared/modules/user/rdo/logged-user.rdo.ts diff --git a/custom.d.ts b/custom.d.ts new file mode 100644 index 0000000..4038b74 --- /dev/null +++ b/custom.d.ts @@ -0,0 +1,7 @@ +import { TTokenPayload } from './src/shared/modules/auth/types/index.js'; + +declare module 'express-serve-static-core' { + export interface Request { + tokenPayload: TTokenPayload; + } +} diff --git a/src/rest/index.ts b/src/rest/index.ts index b72e62e..a9b5362 100644 --- a/src/rest/index.ts +++ b/src/rest/index.ts @@ -7,3 +7,5 @@ export { ValidateObjectIdMiddleware } from './middlewares/validate-objectid.midd export { ValidateDTOMiddleware } from './middlewares/validate-dto.middleware.js'; export { DocumentExistsMiddleware } from './middlewares/document-exists.middleware.js'; export { UploadFileMiddleware } from './middlewares/upload-file.middleware.js'; +export { ParseTokenMiddleware } from './middlewares/parse-token.middleware.js'; +export { PrivateRouteMiddleware } from './middlewares/private-route.middleware.js'; diff --git a/src/rest/middlewares/parse-token.middleware.ts b/src/rest/middlewares/parse-token.middleware.ts new file mode 100644 index 0000000..73cb569 --- /dev/null +++ b/src/rest/middlewares/parse-token.middleware.ts @@ -0,0 +1,40 @@ +import { NextFunction, Request, Response } from 'express'; +import { jwtVerify } from 'jose'; +import { StatusCodes } from 'http-status-codes'; + +import { createSecretKey } from 'node:crypto'; + +import { IMiddleware } from '../types/index.js'; +import { HttpError } from '../index.js'; +import { isTokenPayload } from '../../shared/helpers/index.js'; + +export class ParseTokenMiddleware implements IMiddleware { + 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', + 'ParseTokenMiddleware', + )); + } + } +} diff --git a/src/rest/middlewares/private-route.middleware.ts b/src/rest/middlewares/private-route.middleware.ts new file mode 100644 index 0000000..171209b --- /dev/null +++ b/src/rest/middlewares/private-route.middleware.ts @@ -0,0 +1,19 @@ +import { StatusCodes } from 'http-status-codes'; +import { NextFunction, Request, Response } from 'express'; + +import { IMiddleware } from '../types/index.js'; +import { HttpError } from '../index.js'; + +export class PrivateRouteMiddleware implements IMiddleware { + public async execute({ tokenPayload }: Request, _res: Response, next: NextFunction): Promise { + if (!tokenPayload) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'PrivateRouteMiddleware', + ); + } + + return next(); + } +} diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index e8d4d14..0da06d3 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -7,6 +7,7 @@ import { COMPONENT } from '../shared/constants/index.js'; import { IDatabaseClient } from '../shared/libs/database-client/types/index.js'; import { getMongoURI } from '../shared/helpers/index.js'; import { IController, IExceptionFilter } from './types/index.js'; +import { ParseTokenMiddleware } from './index.js'; @injectable() export class RestApplication { @@ -47,11 +48,13 @@ export class RestApplication { } private 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 initExceptionFilters() { diff --git a/src/rest/types/middleware.interface.ts b/src/rest/types/middleware.interface.ts index f99357d..9ff25e9 100644 --- a/src/rest/types/middleware.interface.ts +++ b/src/rest/types/middleware.interface.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from 'express'; export interface IMiddleware { - execute(req: Request, res: Response, next: NextFunction): void; + execute(req: Request, res: Response, next: NextFunction): Promise | void; } diff --git a/src/shared/helpers/index.ts b/src/shared/helpers/index.ts index 46afc6d..9dc59c1 100644 --- a/src/shared/helpers/index.ts +++ b/src/shared/helpers/index.ts @@ -9,3 +9,4 @@ export { export { getMongoURI } from './database.js'; export { createSHA256 } from './hash.js'; +export { isTokenPayload } from './token.js'; diff --git a/src/shared/helpers/token.ts b/src/shared/helpers/token.ts new file mode 100644 index 0000000..c8cea32 --- /dev/null +++ b/src/shared/helpers/token.ts @@ -0,0 +1,8 @@ +import { TTokenPayload } from '../modules/auth/types/index.js'; + +export const isTokenPayload = (payload: unknown): payload is TTokenPayload => (( + (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') +)); diff --git a/src/shared/modules/auth/default-auth.service.ts b/src/shared/modules/auth/default-auth.service.ts index edc44f4..4d2575e 100644 --- a/src/shared/modules/auth/default-auth.service.ts +++ b/src/shared/modules/auth/default-auth.service.ts @@ -9,7 +9,10 @@ import { LoginUserDTO } from '../user/index.js'; import { IUserService } from '../user/types/index.js'; import { UserEntity } from '../user/user.entity.js'; import { IConfig, TRestSchema } from '../../libs/config/types/index.js'; -import { UserNotFoundException, UserPasswordIncorrectException } from './errors/index.js'; +import { + UserNotFoundException, + UserPasswordIncorrectException, +} from './errors/index.js'; @injectable() export class DefualtAuthService implements IAuthService { @@ -36,8 +39,9 @@ export class DefualtAuthService implements IAuthService { .sign(secretKey); } - public async verfy(dto: LoginUserDTO): Promise { + 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(); diff --git a/src/shared/modules/auth/types/auth-service.interface.ts b/src/shared/modules/auth/types/auth-service.interface.ts index 41d537a..0708bb3 100644 --- a/src/shared/modules/auth/types/auth-service.interface.ts +++ b/src/shared/modules/auth/types/auth-service.interface.ts @@ -3,5 +3,5 @@ import { UserEntity } from '../../user/user.entity.js'; export interface IAuthService { authenticate(user: UserEntity): Promise; - verfy(dto: LoginUserDTO): Promise; + verify(dto: LoginUserDTO): Promise; } diff --git a/src/shared/modules/comment/comment.controller.ts b/src/shared/modules/comment/comment.controller.ts index d5f838f..57f8874 100644 --- a/src/shared/modules/comment/comment.controller.ts +++ b/src/shared/modules/comment/comment.controller.ts @@ -6,6 +6,7 @@ import { ValidateObjectIdMiddleware, ValidateDTOMiddleware, DocumentExistsMiddleware, + PrivateRouteMiddleware, } from '../../../rest/index.js'; import { EHttpMethod } from '../../../rest/types/index.js'; import { COMPONENT } from '../../constants/index.js'; @@ -40,6 +41,7 @@ export class CommentController extends BaseController { method: EHttpMethod.Post, handler: this.create, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new ValidateDTOMiddleware(CreateCommentDTO), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), @@ -53,10 +55,10 @@ export class CommentController extends BaseController { } public async create( - { params, body }: Request, + { params, body, tokenPayload }: Request, res: Response ): Promise { - const result = await this.commentService.create(body, params.offerId); + const result = await this.commentService.create({ ...body, authorId: tokenPayload.id}, params.offerId); this.created(res, fillDTO(CommentRDO, result)); } } diff --git a/src/shared/modules/comment/comment.http b/src/shared/modules/comment/comment.http index 6c2418f..5a8cd7c 100644 --- a/src/shared/modules/comment/comment.http +++ b/src/shared/modules/comment/comment.http @@ -8,10 +8,10 @@ Content-Type: application/json ## Создать комментарий POST http://localhost:4000/comments/66f947e7e706754fb39b93ae HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU { "text": "asddfsdasc 23dfsdfsdf sdfs", - "authorId": "66f947e7e706754fb39b93a7", "rating": 2 } diff --git a/src/shared/modules/comment/dto/create-comment.dto.ts b/src/shared/modules/comment/dto/create-comment.dto.ts index 5009791..4f6369a 100644 --- a/src/shared/modules/comment/dto/create-comment.dto.ts +++ b/src/shared/modules/comment/dto/create-comment.dto.ts @@ -1,4 +1,4 @@ -import { IsMongoId, IsString, IsInt, Min, Max, Length } from 'class-validator'; +import { IsString, IsInt, Min, Max, Length } from 'class-validator'; import { COMMENT_RATING, COMMENT_TEXT_LENGTH } from '../../../constants/index.js'; @@ -7,7 +7,6 @@ export class CreateCommentDTO { @Length(COMMENT_TEXT_LENGTH.MIN, COMMENT_TEXT_LENGTH.MAX) public text!: string; - @IsMongoId() public authorId!: string; @IsInt() diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index 41cc204..05456ee 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -1,5 +1,4 @@ import { - IsMongoId, IsInt, IsString, IsEnum, @@ -72,7 +71,6 @@ export class CreateOfferDTO { @ArrayUnique() public facilities!: EFacilities[]; - @IsMongoId() public authorId!: string; @ValidateNested() diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index d403bd7..630d153 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -8,6 +8,7 @@ import { ValidateObjectIdMiddleware, ValidateDTOMiddleware, DocumentExistsMiddleware, + PrivateRouteMiddleware, } from '../../../rest/index.js'; import { EHttpMethod } from '../../../rest/types/index.js'; import { COMPONENT, RADIX } from '../../constants/index.js'; @@ -37,7 +38,10 @@ export class OfferController extends BaseController { path: '/', method: EHttpMethod.Post, handler: this.create, - middlewares: [new ValidateDTOMiddleware(CreateOfferDTO)] + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateDTOMiddleware(CreateOfferDTO), + ] }); this.addRoute({ path: '/premium', @@ -59,6 +63,7 @@ export class OfferController extends BaseController { method: EHttpMethod.Delete, handler: this.delete, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], @@ -68,6 +73,7 @@ export class OfferController extends BaseController { method: EHttpMethod.Patch, handler: this.update, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new ValidateDTOMiddleware(UpdateOfferDTO), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), @@ -89,10 +95,10 @@ export class OfferController extends BaseController { } public async create( - { body }: Request, Record, CreateOfferDTO>, + { body, tokenPayload }: Request, Record, CreateOfferDTO>, res: Response, ): Promise { - const result = await this.offerService.create(body); + const result = await this.offerService.create({ ...body, authorId: tokenPayload.id }); this.created(res, fillDTO(FullOfferRDO, result)); } diff --git a/src/shared/modules/user/default-user.service.ts b/src/shared/modules/user/default-user.service.ts index 131508a..228bf2d 100644 --- a/src/shared/modules/user/default-user.service.ts +++ b/src/shared/modules/user/default-user.service.ts @@ -28,13 +28,7 @@ export class DefaultUserService implements IUserService { } public async findByEmail(email: string): Promise | null> { - const result = await this.userModel - .aggregate([ - { $match: { email } }, - ]) - .exec(); - - return result[0] || null; + return this.userModel.findOne({ email }); } public async findOrCreate(dto: CreateUserDTO, salt: string): Promise> { diff --git a/src/shared/modules/user/index.ts b/src/shared/modules/user/index.ts index f91c59f..3bb77f5 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -1,6 +1,7 @@ export { CreateUserDTO } from './dto/create-user.dto.js'; export { LoginUserDTO } from './dto/login-user.dto.js'; export { UserRDO } from './rdo/user.rdo.js'; +export { LoggedUserRDO } from './rdo/logged-user.rdo.js'; export { DefaultUserService } from './default-user.service.js'; export { createUserContainer } from './user.container.js'; export { populateFavorites } from './user.aggregation.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..1b3c6bf --- /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 2508787..9c1a66a 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -19,6 +19,8 @@ import { fillDTO } from '../../helpers/index.js'; import { CreateUserDTO, LoginUserDTO, UserRDO } from './index.js'; import { IOfferService, TParamOfferId } from '../offer/types/index.js'; import { ShortOfferRDO } from '../offer/index.js'; +import { IAuthService } from '../auth/types/index.js'; +import { LoggedUserRDO } from './index.js'; const MOCK_USER = '66f947e7e706754fb39b93a7'; @@ -29,6 +31,7 @@ export class UserController extends BaseController { @inject(COMPONENT.USER_SERVICE) private readonly userService: IUserService, @inject(COMPONENT.OFFER_SERVICE) private readonly offerService: IOfferService, @inject(COMPONENT.CONFIG) private readonly config: IConfig, + @inject(COMPONENT.AUTH_SERVICE) private readonly authService: IAuthService, ) { super(logger); @@ -46,6 +49,11 @@ export class UserController extends BaseController { handler: this.login, middlewares: [new ValidateDTOMiddleware(LoginUserDTO)], }); + this.addRoute({ + path: '/login', + method: EHttpMethod.Get, + handler: this.checkAuthenticate, + }); this.addRoute({ path: '/favorites/', method: EHttpMethod.Get, handler: this.showFavorites }); this.addRoute({ path: '/favorites/:offerId', @@ -95,23 +103,15 @@ export class UserController extends BaseController { public async login( { body }: TLoginUserRequest, - _res: Response, + res: Response, ): Promise { - const existsUser = await this.userService.findByEmail(body.email); - - if (!existsUser) { - throw new HttpError( - StatusCodes.BAD_REQUEST, - `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 showFavorites(_req: Request, res: Response): Promise { @@ -144,4 +144,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)); + } } diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index a81103e..503c1c8 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -5,11 +5,11 @@ POST http://localhost:4000/users/register HTTP/1.1 Content-Type: application/json { - "email": "torans@overlook.net", + "email": "new@overlook.net", "avatarPath": "torrance.png", - "name": "Jack", + "name": "Auth", "type": "обычный", - "password": "shining" + "password": "password" } ### @@ -19,12 +19,18 @@ POST http://localhost:4000/users/login HTTP/1.1 Content-Type: application/json { - "email": "torans@overlook.net", - "password": "shining" + "email": "new@overlook.net", + "password": "password" } ### +## Проверить токен пользователя +GET http://localhost:4000/users/login HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU + +### + ## Получение списка избранных предложений GET http://localhost:4000/users/favorites HTTP/1.1 Content-Type: application/json diff --git a/tsconfig.json b/tsconfig.json index a24611b..d9d452b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "outDir": "./dist", "emitDecoratorMetadata": true, "types": [ - "node", + "node" ], "lib": [ "ESNext" @@ -33,5 +33,9 @@ "exclude": [ "node_modules", "**/*.test.ts" - ] + ], + "ts-node": { + "esm": true + }, + "files": ["./custom.d.ts"] } From de259ab6024a722de288cd9c71fe9b68daae6568 Mon Sep 17 00:00:00 2001 From: rcole1919 Date: Sat, 2 Nov 2024 02:37:46 +0400 Subject: [PATCH 3/5] add 401 and 403 errors --- specification/specification.yml | 14 +++++-- .../modules/offer/default-offer.service.ts | 24 ++++++----- src/shared/modules/offer/offer.aggregation.ts | 42 +++++++++++-------- src/shared/modules/offer/offer.controller.ts | 42 ++++++++++++++----- src/shared/modules/offer/offer.http | 13 +++--- .../offer/types/offer-service.interface.ts | 11 ++--- src/shared/modules/user/user.controller.ts | 27 +++++++----- src/shared/modules/user/user.http | 4 ++ 8 files changed, 116 insertions(+), 61 deletions(-) diff --git a/specification/specification.yml b/specification/specification.yml index f5b72a7..4c8f7fe 100644 --- a/specification/specification.yml +++ b/specification/specification.yml @@ -178,7 +178,7 @@ paths: "400": description: Введена некорректная информация - "403": + "401": description: Только авторизованные пользователи могут создавать предложение get: @@ -226,9 +226,12 @@ paths: "400": description: Данные введены некорректно - "403": + "401": description: Только авторизованные пользователи могут редактировать предложение + "403": + description: Доступ к предложению запрещен + delete: tags: - offers @@ -242,9 +245,12 @@ paths: "404": description: Предложение с таким id не найдено. - "403": + "401": description: Только авторизованные пользователи могут удалять предложение + "403": + description: Доступ к предложению запрещен + get: tags: - offers @@ -330,7 +336,7 @@ paths: "404": description: Предложение с таким id не найдено. - "403": + "401": description: Только авторизованные пользователи могут оставлять комментарии components: diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index 72a98d5..71e00be 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -15,8 +15,6 @@ import { ILogger } from '../../libs/logger/types/index.js'; import { ECity, ESortType } from '../../types/index.js'; import { OfferEntity } from './offer.entity.js'; -const MOCK_USER = '66f947e7e706754fb39b93a7'; - @injectable() export class DefaultOfferService implements IOfferService { constructor( @@ -36,26 +34,26 @@ export class DefaultOfferService implements IOfferService { return result; } - public async findById(offerId: string): Promise | null> { + public async findById(offerId: string, userId: string): Promise | null> { const result = await this.offerModel .aggregate([ { $match: { '_id': new Types.ObjectId(offerId) } }, ...populateComments, ...populateAuthor, - ...getIsFavorite(MOCK_USER, offerId), + ...getIsFavorite(userId, offerId), ]) .exec(); return result[0] || null; } - public async find(count?: number): Promise[]> { + public async find(count: number, userId: string): Promise[]> { const limit = count || DEFAULT_OFFER_COUNT; const result = await this.offerModel .aggregate([ ...populateComments, - ...getIsFavorite(MOCK_USER), + ...getIsFavorite(userId), { $sort: { createdAt: ESortType.Desc } }, { $limit: limit }, ]) @@ -64,7 +62,7 @@ export class DefaultOfferService implements IOfferService { return result; } - public async findPremium(city: ECity): Promise[]> { + public async findPremium(city: ECity, userId: string): Promise[]> { return this.offerModel .aggregate([ { $match: { @@ -72,20 +70,26 @@ export class DefaultOfferService implements IOfferService { isPremium: true, } }, ...populateComments, - ...getIsFavorite(MOCK_USER), + ...getIsFavorite(userId), { $sort: { createdAt: ESortType.Desc } }, { $limit: MAX_PREMIUM_NUMBER }, ]); } - public async updateById(offerId: string, dto: UpdateOfferDTO): Promise | null> { + public async updateById(offerId: string, _userId: string, dto: UpdateOfferDTO): Promise | null> { return this.offerModel .findByIdAndUpdate(offerId, dto, { new: true }); } - public async deleteById(offerId: string): Promise | null> { + public async deleteById(offerId: string, _userId: string): Promise | null> { return this.offerModel .findByIdAndDelete(offerId) .exec(); } + + public async isOwnOffer(offerId: string, userId: string): Promise { + const offer = await this.offerModel.findOne({ _id: offerId }); + + return offer?.authorId?.toString() === userId; + } } diff --git a/src/shared/modules/offer/offer.aggregation.ts b/src/shared/modules/offer/offer.aggregation.ts index 0ddb658..147ae2b 100644 --- a/src/shared/modules/offer/offer.aggregation.ts +++ b/src/shared/modules/offer/offer.aggregation.ts @@ -53,20 +53,28 @@ export const populateComments = [ { $unset: 'comments' }, ]; -export const getIsFavorite = (userId: string, offerId: string = '') => ([ - { - $lookup: { - from: 'users', - pipeline: [ - { $match: { '_id': new Types.ObjectId(userId) } }, - { $project: { favorites: 1 } } - ], - as: 'currentUser' - }, - }, - { $unwind: '$currentUser' }, - { $addFields: { isFavorite: { - $in: [offerId ? new Types.ObjectId(offerId) : '$_id' , '$currentUser.favorites'] - } }}, - { $unset: 'currentUser' } -]); +export const getIsFavorite = (userId: string, offerId: string = '') => { + if (userId) { + return [ + { + $lookup: { + from: 'users', + pipeline: [ + { $match: { '_id': new Types.ObjectId(userId) } }, + { $project: { favorites: 1 } } + ], + as: 'currentUser' + }, + }, + { $unwind: '$currentUser' }, + { $addFields: { isFavorite: { + $in: [offerId ? new Types.ObjectId(offerId) : '$_id' , '$currentUser.favorites'] + } }}, + { $unset: 'currentUser' } + ]; + } + + return [ + { $addFields: { isFavorite: false } }, + ]; +}; diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index 630d153..af72bf4 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -81,7 +81,7 @@ export class OfferController extends BaseController { }); } - public async index({ query }: Request, res: Response): Promise { + public async index({ query, tokenPayload }: Request, res: Response): Promise { if (query.count !== undefined && !Number.parseInt(query.count as string, RADIX)) { throw new HttpError( StatusCodes.BAD_REQUEST, @@ -89,7 +89,10 @@ export class OfferController extends BaseController { 'UserController', ); } - const offers = await this.offerService.find(Number.parseInt(query?.count as string, RADIX)); + const offers = await this.offerService.find( + Number.parseInt(query?.count as string, RADIX), + tokenPayload?.id, + ); const responseData = fillDTO(ShortOfferRDO, offers); this.ok(res, responseData); } @@ -102,31 +105,50 @@ export class OfferController extends BaseController { this.created(res, fillDTO(FullOfferRDO, result)); } - public async show(req: Request, res: Response): Promise { - const existsOffer = await this.offerService.findById(req.params.offerId); + public async show({ tokenPayload, params }: Request, res: Response): Promise { + const existsOffer = await this.offerService.findById(params.offerId, tokenPayload?.id); const responseData = fillDTO(FullOfferRDO, existsOffer); this.ok(res, responseData); } - public async delete(req: Request, res: Response): Promise { - const result = await this.offerService.deleteById(req.params.offerId); + public async delete({ tokenPayload, params }: Request, res: Response): Promise { + if (! (await this.offerService.isOwnOffer(params.offerId, tokenPayload.id))) { + throw new HttpError( + StatusCodes.FORBIDDEN, + 'Forbidden', + 'OfferController', + ); + } + const result = await this.offerService.deleteById(params.offerId, tokenPayload.id); this.noContent(res, result); } public async update( - req: Request, + { body, params, tokenPayload }: Request, res: Response, ): Promise { - const result = await this.offerService.updateById(req.params.offerId, req.body as UpdateOfferDTO); + if (! (await this.offerService.isOwnOffer(params.offerId, tokenPayload.id))) { + throw new HttpError( + StatusCodes.FORBIDDEN, + 'Forbidden', + 'OfferController', + ); + } + + const result = await this.offerService.updateById( + params.offerId, + tokenPayload.id, + body as UpdateOfferDTO, + ); this.ok(res, fillDTO(FullOfferRDO, result)); } public async premium( - { body }: Request, Record, PremiumOfferDTO>, + { body, tokenPayload }: Request, Record, PremiumOfferDTO>, res: Response, ): Promise { - const result = await this.offerService.findPremium(body.city); + const result = await this.offerService.findPremium(body.city, tokenPayload?.id); this.ok(res, fillDTO(FullOfferRDO, result)); } } diff --git a/src/shared/modules/offer/offer.http b/src/shared/modules/offer/offer.http index 7dca8ee..5ba1395 100644 --- a/src/shared/modules/offer/offer.http +++ b/src/shared/modules/offer/offer.http @@ -2,18 +2,21 @@ ## Получить список предложений GET http://localhost:4000/offers HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ### ## Получить предложение по id GET http://localhost:4000/offers/66f947e7e706754fb39b93ae HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ### ## Удалить предложение по id -DELETE http://localhost:4000/offers/66f947e7e706754fb39b93a4 HTTP/1.1 +DELETE http://localhost:4000/offers/672555f786660d7734313d1d HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ### @@ -30,20 +33,20 @@ Content-Type: application/json ## Создать предложение POST http://localhost:4000/offers HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU { - "title": "ычмчам23423 ыыыСозданный432423423", - "description": "JGdsfdfsdf", + "title": "For test eeeeee", + "description": "JGdsfdfsdf dwewedwe ", "city": "Paris", "previewImagePath": "sfsdf.jpg", - "photos": ["sdfsfaa.jpg", "sccccc.jpg"], + "photos": ["sdfsfaa.jpg", "scc3ccc.jpg", "sccccc.jpg", "sccc.jpg", "scccc.jpg", "scceccc.jpg"], "isPremium": true, "housingType": "house", "roomsNumber": 3, "visitorsNumber": 3, "price": 10000, "facilities": ["Breakfast", "Air conditioning"], - "authorId": "66f947e7e706754fb39b93a7", "coords": { "latitude": 50.846557, "longitude": 4.351697 diff --git a/src/shared/modules/offer/types/offer-service.interface.ts b/src/shared/modules/offer/types/offer-service.interface.ts index 932ea16..76319bc 100644 --- a/src/shared/modules/offer/types/offer-service.interface.ts +++ b/src/shared/modules/offer/types/offer-service.interface.ts @@ -7,9 +7,10 @@ import { ECity } from '../../../types/index.js'; export interface IOfferService extends IDocumentExists { create(dto: CreateOfferDTO): Promise>; - findById(offerId: string): Promise | null>; - find(count?: number): Promise[]>; - deleteById(offerId: string): Promise | null>; - updateById(offerId: string, dto: UpdateOfferDTO): Promise | null>; - findPremium(city: ECity): Promise[]>; + findById(offerId: string, userId: string): Promise | null>; + find(count: number, userId: string): Promise[]>; + deleteById(offerId: string, userId: string): Promise | null>; + updateById(offerId: string, userId: string, dto: UpdateOfferDTO): Promise | null>; + findPremium(city: ECity, userId: string): Promise[]>; + isOwnOffer(offerId: string, userId: string): Promise; } diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index 9c1a66a..2989ecf 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -9,6 +9,7 @@ import { ValidateDTOMiddleware, DocumentExistsMiddleware, UploadFileMiddleware, + PrivateRouteMiddleware, } from '../../../rest/index.js'; import { EHttpMethod } from '../../../rest/types/index.js'; import { ILogger } from '../../libs/logger/types/index.js'; @@ -22,8 +23,6 @@ import { ShortOfferRDO } from '../offer/index.js'; import { IAuthService } from '../auth/types/index.js'; import { LoggedUserRDO } from './index.js'; -const MOCK_USER = '66f947e7e706754fb39b93a7'; - @injectable() export class UserController extends BaseController { constructor( @@ -54,12 +53,18 @@ export class UserController extends BaseController { method: EHttpMethod.Get, handler: this.checkAuthenticate, }); - this.addRoute({ path: '/favorites/', method: EHttpMethod.Get, handler: this.showFavorites }); + this.addRoute({ + path: '/favorites/', + method: EHttpMethod.Get, + handler: this.showFavorites, + middlewares: [new PrivateRouteMiddleware()] + }); this.addRoute({ path: '/favorites/:offerId', method: EHttpMethod.Post, handler: this.addFavorite, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], @@ -69,6 +74,7 @@ export class UserController extends BaseController { method: EHttpMethod.Delete, handler: this.deleteFavorite, middlewares: [ + new PrivateRouteMiddleware(), new ValidateObjectIdMiddleware('offerId'), new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], @@ -78,6 +84,7 @@ export class UserController extends BaseController { method: EHttpMethod.Post, handler: this.uploadAvatar, middlewares: [ + new PrivateRouteMiddleware(), new UploadFileMiddleware(this.config.get('UPLOAD_DIRECTORY'), 'avatarPath'), ] }); @@ -114,13 +121,13 @@ export class UserController extends BaseController { this.ok(res, responseData); } - public async showFavorites(_req: Request, res: Response): Promise { - const result = await this.userService.getFavorites(MOCK_USER); + public async showFavorites({ tokenPayload }: Request, res: Response): Promise { + const result = await this.userService.getFavorites(tokenPayload.id); this.ok(res, fillDTO(ShortOfferRDO, result)); } - public async addFavorite({ params }: Request, res: Response): Promise { - const favorites = await this.userService.getFavorites(MOCK_USER); + public async addFavorite({ params, tokenPayload }: Request, res: Response): Promise { + const favorites = await this.userService.getFavorites(tokenPayload.id); if (favorites.map((item) => item._id.toString()).includes(params.offerId)) { throw new HttpError( @@ -130,12 +137,12 @@ export class UserController extends BaseController { ); } - const result = await this.userService.addFavorite(MOCK_USER, params.offerId); + const result = await this.userService.addFavorite(tokenPayload.id, params.offerId); this.ok(res, fillDTO(UserRDO, result)); } - public async deleteFavorite({ params }: Request, res: Response): Promise { - const result = await this.userService.deleteFavorite(MOCK_USER, params.offerId); + public async deleteFavorite({ params, tokenPayload }: Request, res: Response): Promise { + const result = await this.userService.deleteFavorite(tokenPayload.id, params.offerId); this.ok(res, fillDTO(UserRDO, result)); } diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index 503c1c8..6886605 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -34,15 +34,19 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiL ## Получение списка избранных предложений GET http://localhost:4000/users/favorites HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ### ## Добавление предложения в избранное POST http://localhost:4000/users/favorites/66f947e7e706754fb39b93ae HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU + ### POST http://localhost:4000/users/favorites/66f947e7e706754fb39b93bb HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ### From 0b17bfe3ec697541032a586292e482bbca8fd7a5 Mon Sep 17 00:00:00 2001 From: rcole1919 Date: Sat, 2 Nov 2024 22:50:04 +0400 Subject: [PATCH 4/5] add cors; update workflow.md --- Workflow.md | 14 ++++++++++++++ package-lock.json | 22 ++++++++++++++++++++-- package.json | 2 ++ src/rest/rest.application.ts | 2 ++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Workflow.md b/Workflow.md index 8b87981..d82987d 100644 --- a/Workflow.md +++ b/Workflow.md @@ -66,6 +66,20 @@ npm start В процессе запуска проекта будет выполнен процесс «Сборки проекта» и запуска результирующего кода. +#### Запустить проект в дев режиме + +```bash +npm run start:dev +``` + +В процессе запуска проекта будет выполнен процесс «Сборки проекта» и запуска результирующего кода на этапе раработки с отслеживанием внесения правок. + +#### Запустить сервер с моковыми данными + +```bash +npm run mock:server +``` + ## Структура проекта ### Директория `src` diff --git a/package-lock.json b/package-lock.json index efc2386..9ed8e56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "class-validator": "0.14.1", "convict": "6.2.4", "convict-format-with-validator": "6.2.0", + "cors": "2.8.5", "dayjs": "1.11.10", "dotenv": "16.4.5", "express": "4.19.2", @@ -31,6 +32,7 @@ "devDependencies": { "@types/convict": "6.1.6", "@types/convict-format-with-validator": "6.0.5", + "@types/cors": "2.8.17", "@types/express": "4.17.21", "@types/mime-types": "2.1.4", "@types/multer": "1.4.11", @@ -502,6 +504,15 @@ "@types/convict": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -1753,7 +1764,6 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, "dependencies": { "object-assign": "^4", "vary": "^1" @@ -7784,6 +7794,15 @@ "@types/convict": "*" } }, + "@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -8704,7 +8723,6 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, "requires": { "object-assign": "^4", "vary": "^1" diff --git a/package.json b/package.json index 4bc98ac..b8a091b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "devDependencies": { "@types/convict": "6.1.6", "@types/convict-format-with-validator": "6.0.5", + "@types/cors": "2.8.17", "@types/express": "4.17.21", "@types/mime-types": "2.1.4", "@types/multer": "1.4.11", @@ -49,6 +50,7 @@ "class-validator": "0.14.1", "convict": "6.2.4", "convict-format-with-validator": "6.2.0", + "cors": "2.8.5", "dayjs": "1.11.10", "dotenv": "16.4.5", "express": "4.19.2", diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index 0da06d3..b9cf46b 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -1,5 +1,6 @@ import { inject, injectable } from 'inversify'; import express, { Express } from 'express'; +import cors from 'cors'; import { ILogger } from '../shared/libs/logger/types/index.js'; import { IConfig, TRestSchema } from '../shared/libs/config/types/index.js'; @@ -55,6 +56,7 @@ export class RestApplication { express.static(this.config.get('UPLOAD_DIRECTORY')), ); this.server.use(authenticateMiddleware.execute.bind(authenticateMiddleware)); + this.server.use(cors()); } private initExceptionFilters() { From bc4600e7b0505e961dad01dbe97372bd5fed4237 Mon Sep 17 00:00:00 2001 From: rcole1919 Date: Mon, 4 Nov 2024 00:23:13 +0400 Subject: [PATCH 5/5] fix spec --- specification/specification.yml | 26 ++++++++++++++++--- .../user-password-incorrect.exception.ts | 2 +- src/shared/modules/offer/offer.http | 5 ++-- src/shared/modules/user/user.controller.ts | 2 +- src/shared/modules/user/user.http | 10 ++++--- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/specification/specification.yml b/specification/specification.yml index 4c8f7fe..c73f3ac 100644 --- a/specification/specification.yml +++ b/specification/specification.yml @@ -68,8 +68,11 @@ paths: schema: $ref: '#/components/schemas/user' - "400": - description: Несуществующий логин или пароль. + "404": + description: Несуществующий логин. + + "401": + description: Неправильный пароль. get: tags: @@ -85,8 +88,8 @@ paths: schema: $ref: '#/components/schemas/user' - "404": - description: Пользователь не найден. + "401": + description: Невалидный токен. /users/favorites: get: @@ -117,6 +120,9 @@ paths: "200": description: Предложение добавлено в избранное. + "400": + description: Невалидный id предложения + "404": description: Предложение с таким id не найдено. @@ -136,6 +142,9 @@ paths: "204": description: Предложение удалено из избранного. + "400": + description: Невалидный id предложения + "404": description: Предложение с таким id не найдено. @@ -245,6 +254,9 @@ paths: "404": description: Предложение с таким id не найдено. + "400": + description: Невалидный id предложения + "401": description: Только авторизованные пользователи могут удалять предложение @@ -265,6 +277,9 @@ paths: schema: $ref: '#/components/schemas/offer' + "400": + description: Невалидный id предложения + "404": description: Предложение с таким id не найдено. @@ -309,6 +324,9 @@ paths: schema: $ref: '#/components/schemas/comments' + "400": + description: Невалидный id предложения + "404": description: Предложение с таким id не найдено. diff --git a/src/shared/modules/auth/errors/user-password-incorrect.exception.ts b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts index d95b83a..42ec766 100644 --- a/src/shared/modules/auth/errors/user-password-incorrect.exception.ts +++ b/src/shared/modules/auth/errors/user-password-incorrect.exception.ts @@ -4,6 +4,6 @@ import { BaseUserException } from './index.js'; export class UserPasswordIncorrectException extends BaseUserException { constructor() { - super(StatusCodes.UNAUTHORIZED, 'Incorrect user name or password'); + super(StatusCodes.UNAUTHORIZED, 'Incorrect password'); } } diff --git a/src/shared/modules/offer/offer.http b/src/shared/modules/offer/offer.http index 5ba1395..760a7b0 100644 --- a/src/shared/modules/offer/offer.http +++ b/src/shared/modules/offer/offer.http @@ -21,11 +21,12 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiL ### ## Обновить предложение по id -PATCH http://localhost:4000/offers/66f947e7e706754fb39b93a9 HTTP/1.1 +PATCH http://localhost:4000/offers/6727dae4f578438f2e94fe24 HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU { - "title": "d" + "title": "dasdasdasj asasuoain asdoasind" } ### diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index 2989ecf..318fdca 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -143,7 +143,7 @@ export class UserController extends BaseController { public async deleteFavorite({ params, tokenPayload }: Request, res: Response): Promise { const result = await this.userService.deleteFavorite(tokenPayload.id, params.offerId); - this.ok(res, fillDTO(UserRDO, result)); + this.noContent(res, fillDTO(UserRDO, result)); } public async uploadAvatar(req: Request, res: Response): Promise { diff --git a/src/shared/modules/user/user.http b/src/shared/modules/user/user.http index 6886605..26d4fd3 100644 --- a/src/shared/modules/user/user.http +++ b/src/shared/modules/user/user.http @@ -5,11 +5,11 @@ POST http://localhost:4000/users/register HTTP/1.1 Content-Type: application/json { - "email": "new@overlook.net", + "email": "nedw@overlook.net", "avatarPath": "torrance.png", "name": "Auth", "type": "обычный", - "password": "password" + "password": "0h" } ### @@ -20,7 +20,7 @@ Content-Type: application/json { "email": "new@overlook.net", - "password": "password" + "password": "passwordd" } ### @@ -39,7 +39,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiL ### ## Добавление предложения в избранное -POST http://localhost:4000/users/favorites/66f947e7e706754fb39b93ae HTTP/1.1 +POST http://localhost:4000/users/favorites/66f947e7e706754fb39b93bb HTTP/1.1 Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU @@ -53,6 +53,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiL ## Удаление предложения из избранного DELETE http://localhost:4000/users/favorites/66f947e7e706754fb39b93bb HTTP/1.1 Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ### @@ -60,6 +61,7 @@ Content-Type: application/json POST http://localhost:4000/users/avatar HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im5ld0BvdmVybG9vay5uZXQiLCJuYW1lIjoiQXV0aCIsImlkIjoiNjcyNTMxMDdjNjE2NzZlYTczNGE4YjAzIiwiaWF0IjoxNzMwNDk1NjEyLCJleHAiOjE3MzA2Njg0MTJ9.KbhaENJBtEWavsUY4oLOjrYF0N4XhmJkOxXPJTWSjdU ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="avatarPath"; filename="screen.png"