Skip to content

Commit

Permalink
Merge pull request #11 from BlazyOne/module8-task1
Browse files Browse the repository at this point in the history
  • Loading branch information
keksobot authored Oct 30, 2024
2 parents 4286824 + edbec6c commit 8a718e2
Show file tree
Hide file tree
Showing 27 changed files with 318 additions and 18 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ DB_PASSWORD=test
DB_PORT=27017
DB_NAME=six-cities
UPLOAD_DIRECTORY=/home/node/app/upload
JWT_SECRET=secret
7 changes: 7 additions & 0 deletions custom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenPayload } from './src/shared/modules/auth/index.js';

declare module 'express-serve-static-core' {
export interface Request {
tokenPayload: TokenPayload;
}
}
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/main.rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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(
createRestApplicationContainer(),
createUserContainer(),
createOfferContainer(),
createCommentContainer(),
createAuthContainer(),
);

const application = appContainer.get<RestApplication>(
Expand Down
19 changes: 18 additions & 1 deletion src/rest/rest.application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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),
);
Expand Down
7 changes: 7 additions & 0 deletions src/shared/libs/config/rest.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type RestSchema = {
DB_PORT: string;
DB_NAME: string;
UPLOAD_DIRECTORY: string;
JWT_SECRET: string;
};

export const configRestSchema = convict<RestSchema>({
Expand Down Expand Up @@ -63,4 +64,10 @@ export const configRestSchema = convict<RestSchema>({
env: 'UPLOAD_DIRECTORY',
default: null,
},
JWT_SECRET: {
doc: 'Secret for sign JWT',
format: String,
env: 'JWT_SECRET',
default: null,
},
});
2 changes: 2 additions & 0 deletions src/shared/libs/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
61 changes: 61 additions & 0 deletions src/shared/libs/rest/middleware/parse-token.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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',
),
);
}
}
}
23 changes: 23 additions & 0 deletions src/shared/libs/rest/middleware/private-route.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!tokenPayload) {
throw new HttpError(
StatusCodes.UNAUTHORIZED,
'Unauthorized',
'PrivateRouteMiddleware',
);
}

return next();
}
}
6 changes: 6 additions & 0 deletions src/shared/modules/auth/auth-service.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { LoginUserDto, UserEntity } from '../user/index.js';

export interface AuthService {
authenticate(user: UserEntity): Promise<string>;
verify(dto: LoginUserDto): Promise<UserEntity>;
}
2 changes: 2 additions & 0 deletions src/shared/modules/auth/auth.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const JWT_ALGORITHM = 'HS256';
export const JWT_EXPIRED = '2d';
21 changes: 21 additions & 0 deletions src/shared/modules/auth/auth.container.ts
Original file line number Diff line number Diff line change
@@ -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<AuthService>(Component.AuthService)
.to(DefaultAuthService)
.inSingletonScope();
authContainer
.bind<ExceptionFilter>(Component.AuthExceptionFilter)
.to(AuthExceptionFilter)
.inSingletonScope();

return authContainer;
}
30 changes: 30 additions & 0 deletions src/shared/modules/auth/auth.exception-filter.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
55 changes: 55 additions & 0 deletions src/shared/modules/auth/default-auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<RestSchema>,
) {}

public async authenticate(user: UserEntity): Promise<string> {
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<UserEntity> {
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;
}
}
7 changes: 7 additions & 0 deletions src/shared/modules/auth/errors/base-user.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpError } from '../../../libs/rest/index.js';

export class BaseUserException extends HttpError {
constructor(httpStatusCode: number, message: string) {
super(httpStatusCode, message);
}
}
3 changes: 3 additions & 0 deletions src/shared/modules/auth/errors/index.ts
Original file line number Diff line number Diff line change
@@ -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';
8 changes: 8 additions & 0 deletions src/shared/modules/auth/errors/user-not-found.exception.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
Original file line number Diff line number Diff line change
@@ -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');
}
}
4 changes: 4 additions & 0 deletions src/shared/modules/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 5 additions & 0 deletions src/shared/modules/auth/types/TokenPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type TokenPayload = {
email: string;
name: string;
id: string;
};
1 change: 1 addition & 0 deletions src/shared/modules/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading

0 comments on commit 8a718e2

Please sign in to comment.