diff --git a/.env.example b/.env.example index 8f62d0e..503a286 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ DB_PASSWORD=test DB_PORT=27017 DB_NAME=six-cities UPLOAD_DIRECTORY=/home/node/app/upload +JWT_SECRET=secret diff --git a/custom.d.ts b/custom.d.ts new file mode 100644 index 0000000..778a665 --- /dev/null +++ b/custom.d.ts @@ -0,0 +1,7 @@ +import { TokenPayload } from './src/shared/modules/auth/index.js'; + +declare module 'express-serve-static-core' { + export interface Request { + tokenPayload: TokenPayload; + } +} diff --git a/package-lock.json b/package-lock.json index 28bf987..78e9658 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.7.0", "multer": "1.4.5-lts.1", @@ -4340,6 +4341,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", @@ -10594,6 +10603,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 51a9ced..8d980f6 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,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.7.0", "multer": "1.4.5-lts.1", diff --git a/src/main.rest.ts b/src/main.rest.ts index b29fb22..51005d9 100644 --- a/src/main.rest.ts +++ b/src/main.rest.ts @@ -6,6 +6,7 @@ import { createRestApplicationContainer } from './rest/rest.container.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( diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index e178706..4bc427e 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -5,7 +5,11 @@ import { Logger } from '../shared/libs/logger/index.js'; import { Component } from '../shared/types/index.js'; import { DatabaseClient } from '../shared/libs/database-client/index.js'; import { getMongoURI } from '../shared/helpers/index.js'; -import { Controller, ExceptionFilter } from '../shared/libs/rest/index.js'; +import { + Controller, + ExceptionFilter, + ParseTokenMiddleware, +} from '../shared/libs/rest/index.js'; @injectable() export class RestApplication { @@ -22,6 +26,8 @@ export class RestApplication { private readonly userController: Controller, @inject(Component.OfferController) private readonly offerController: Controller, + @inject(Component.AuthExceptionFilter) + private readonly authExceptionFilter: ExceptionFilter, ) { this.server = express(); } @@ -60,16 +66,27 @@ export class RestApplication { private initMiddleware() { this.logger.info('Init app-level 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('App-level middleware initialization completed'); } private initExceptionFilters() { this.logger.info('Init exception filters'); + this.server.use( + this.authExceptionFilter.catch.bind(this.authExceptionFilter), + ); this.server.use( this.appExceptionFilter.catch.bind(this.appExceptionFilter), ); diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index ad76a7a..e30ec7d 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/index.ts b/src/shared/libs/rest/index.ts index e36e09b..a74ad7a 100644 --- a/src/shared/libs/rest/index.ts +++ b/src/shared/libs/rest/index.ts @@ -11,3 +11,5 @@ export { ValidateObjectIdMiddleware } from './middleware/validate-objectid.middl export { ValidateDtoMiddleware } from './middleware/validate-dto.middleware.js'; export { DocumentExistsMiddleware } from './middleware/document-exists.middleware.js'; export { UploadFileMiddleware } from './middleware/upload-file.middleware.js'; +export { ParseTokenMiddleware } from './middleware/parse-token.middleware.js'; +export { PrivateRouteMiddleware } from './middleware/private-route.middleware.js'; 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..cb8e1b3 --- /dev/null +++ b/src/shared/libs/rest/middleware/parse-token.middleware.ts @@ -0,0 +1,61 @@ +import { NextFunction, Request, Response } from 'express'; +import { jwtVerify } from 'jose'; +import { StatusCodes } from 'http-status-codes'; + +import { createSecretKey } from 'node:crypto'; + +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; +import { TokenPayload } from '../../../modules/auth/index.js'; + +function isTokenPayload(payload: unknown): payload is TokenPayload { + return ( + typeof payload === 'object' && + payload !== null && + 'email' in payload && + typeof payload.email === 'string' && + '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( + req: Request, + _res: Response, + next: NextFunction, + ): Promise { + const authorizationHeader = req.headers?.authorization?.split(' '); + if (!authorizationHeader) { + return next(); + } + + const [, token] = authorizationHeader; + + try { + const { payload } = await jwtVerify( + token, + createSecretKey(this.jwtSecret, 'utf-8'), + ); + + if (isTokenPayload(payload)) { + req.tokenPayload = { ...payload }; + return next(); + } else { + throw new Error('Bad token'); + } + } catch { + return next( + new HttpError( + StatusCodes.UNAUTHORIZED, + 'Invalid token', + 'AuthenticateMiddleware', + ), + ); + } + } +} 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..d4bbb37 --- /dev/null +++ b/src/shared/libs/rest/middleware/private-route.middleware.ts @@ -0,0 +1,23 @@ +import { StatusCodes } from 'http-status-codes'; +import { NextFunction, Request, Response } from 'express'; + +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; + +export class PrivateRouteMiddleware implements Middleware { + public async execute( + { tokenPayload }: Request, + _res: Response, + next: NextFunction, + ): Promise { + if (!tokenPayload) { + throw new HttpError( + StatusCodes.UNAUTHORIZED, + 'Unauthorized', + 'PrivateRouteMiddleware', + ); + } + + return next(); + } +} diff --git a/src/shared/modules/auth/auth-service.interface.ts b/src/shared/modules/auth/auth-service.interface.ts new file mode 100644 index 0000000..9d899c5 --- /dev/null +++ b/src/shared/modules/auth/auth-service.interface.ts @@ -0,0 +1,6 @@ +import { LoginUserDto, UserEntity } from '../user/index.js'; + +export interface AuthService { + authenticate(user: UserEntity): Promise; + verify(dto: LoginUserDto): Promise; +} diff --git a/src/shared/modules/auth/auth.constant.ts b/src/shared/modules/auth/auth.constant.ts new file mode 100644 index 0000000..269747d --- /dev/null +++ b/src/shared/modules/auth/auth.constant.ts @@ -0,0 +1,2 @@ +export const JWT_ALGORITHM = 'HS256'; +export const JWT_EXPIRED = '2d'; diff --git a/src/shared/modules/auth/auth.container.ts b/src/shared/modules/auth/auth.container.ts new file mode 100644 index 0000000..d8b8c58 --- /dev/null +++ b/src/shared/modules/auth/auth.container.ts @@ -0,0 +1,21 @@ +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..3f171e5 --- /dev/null +++ b/src/shared/modules/auth/auth.exception-filter.ts @@ -0,0 +1,30 @@ +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 { Request, Response, NextFunction } 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, + _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/default-auth.service.ts b/src/shared/modules/auth/default-auth.service.ts new file mode 100644 index 0000000..8830fc6 --- /dev/null +++ b/src/shared/modules/auth/default-auth.service.ts @@ -0,0 +1,55 @@ +import { inject, injectable } from 'inversify'; +import * as crypto from 'node:crypto'; +import { AuthService } from './auth-service.interface.js'; +import { Component } from '../../types/index.js'; +import { Logger } from '../../libs/logger/index.js'; +import { LoginUserDto, UserEntity, UserService } from '../user/index.js'; +import { Config, RestSchema } from '../../libs/config/index.js'; +import { TokenPayload } from './types/TokenPayload.js'; +import { SignJWT } from 'jose'; +import { JWT_ALGORITHM, JWT_EXPIRED } from './auth.constant.js'; +import { + UserNotFoundException, + UserPasswordIncorrectException, +} from './errors/index.js'; + +@injectable() +export class DefaultAuthService implements AuthService { + constructor( + @inject(Component.Logger) private readonly logger: Logger, + @inject(Component.UserService) private readonly userService: UserService, + @inject(Component.Config) private readonly config: Config, + ) {} + + public async authenticate(user: UserEntity): Promise { + const jwtSecret = this.config.get('JWT_SECRET'); + const secretKey = crypto.createSecretKey(jwtSecret, 'utf-8'); + const tokenPayload: TokenPayload = { + email: user.email, + 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 verify(dto: LoginUserDto): Promise { + const user = await this.userService.findByEmail(dto.email); + if (!user) { + this.logger.warn(`User with ${dto.email} not found`); + throw new UserNotFoundException(); + } + + if (!user.verifyPassword(dto.password, this.config.get('SALT'))) { + this.logger.warn(`Incorrect password for ${dto.email}`); + throw new UserPasswordIncorrectException(); + } + + return user; + } +} diff --git a/src/shared/modules/auth/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..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..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..9239aa1 --- /dev/null +++ b/src/shared/modules/auth/index.ts @@ -0,0 +1,4 @@ +export { AuthService } from './auth-service.interface.js'; +export { TokenPayload } from './types/TokenPayload.js'; +export { createAuthContainer } from './auth.container.js'; +export { DefaultAuthService } from './default-auth.service.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/user/index.ts b/src/shared/modules/user/index.ts index 9e7f5a9..5e03c60 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -5,3 +5,4 @@ export { DefaultUserService } from './default-user.service.js'; export { createUserContainer } from './user.container.js'; export { UserController } from './user.controller.js'; export { UserRdo } from './rdo/user.rdo.js'; +export { LoginUserDto } from './dto/login-user.dto.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 44fc07e..2eb829b 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -19,6 +19,8 @@ import { UserRdo } from './rdo/user.rdo.js'; import { LoginUserRequest } from './type/login-user-request.type.js'; import { CreateUserDto } from './dto/create-user.dto.js'; import { LoginUserDto } from './dto/login-user.dto.js'; +import { AuthService } from '../auth/index.js'; +import { LoggedUserRdo } from './rdo/logged-user.rdo.js'; @injectable() export class UserController extends BaseController { @@ -27,6 +29,7 @@ export class UserController extends BaseController { @inject(Component.UserService) private readonly userService: UserService, @inject(Component.Config) private readonly configService: Config, + @inject(Component.AuthService) private readonly authService: AuthService, ) { super(logger); @@ -82,22 +85,14 @@ export class UserController extends BaseController { this.created(res, fillDTO(UserRdo, result)); } - public async login({ body }: LoginUserRequest, _res: Response) { - const user = await this.userService.findByEmail(body.email); - - if (!user) { - throw new HttpError( - StatusCodes.UNAUTHORIZED, - `User with email ${body.email} not found.`, - 'UserController', - ); - } - - throw new HttpError( - StatusCodes.NOT_IMPLEMENTED, - '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(req: Request, res: Response) { diff --git a/src/shared/modules/user/user.entity.ts b/src/shared/modules/user/user.entity.ts index 032e39d..931b4d5 100644 --- a/src/shared/modules/user/user.entity.ts +++ b/src/shared/modules/user/user.entity.ts @@ -49,6 +49,11 @@ export class UserEntity extends defaultClasses.TimeStamps implements User { public getPassword() { return this.password; } + + public verifyPassword(password: string, salt: string) { + const hashPassword = createSHA256(password, salt); + return hashPassword === this.password; + } } export const UserModel = getModelForClass(UserEntity); diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index eb7905e..4e6caeb 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -12,4 +12,6 @@ export const Component = { ExceptionFilter: Symbol.for('ExceptionFilter'), UserController: Symbol.for('UserController'), OfferController: Symbol.for('OfferController'), + AuthService: Symbol.for('AuthService'), + AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), } as const; diff --git a/tsconfig.json b/tsconfig.json index 1065938..139f6f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,9 @@ "lib": ["ESNext"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "**/*.test.ts"] + "exclude": ["node_modules", "**/*.test.ts"], + "ts-node": { + "esm": true + }, + "files": ["./custom.d.ts"] }