diff --git a/.env.example b/.env.example index fe80dbc..31ab3e2 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ POSTGRES_PASSWORD=mypassword POSTGRES_DB=home-library POSTGRES_PORT=5432 DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public" + +LOGS_MAX_FILE_SIZE_KB=10 +LOGGING_LEVEL=3 diff --git a/environment.d.ts b/environment.d.ts index c802d9b..d70793b 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -11,5 +11,7 @@ namespace NodeJS { POSTGRES_DB:string; POSTGRES_PORT:string; DATABASE_URL:string; + LOGS_MAX_FILE_SIZE_KB:string; + LOGGING_LEVEL:string; } } diff --git a/package.json b/package.json index 2a72cf4..12191cf 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ "start:prod": "node dist/src/main.js", "lint": "eslint \"{src,apps,libs,test,types,db}/**/*.ts\" --fix", "type-check": "tsc --noEmit", - "test": "jest --testPathIgnorePatterns refresh.e2e.spec.ts --noStackTrace --runInBand", - "test:noAuth": "jest --testMatch \"/*.spec.ts\" --noStackTrace --runInBand", + "test": "jest --testMatch \"/*.spec.ts\" --noStackTrace --runInBand", "test:auth": "cross-env TEST_MODE=auth jest --testPathIgnorePatterns refresh.e2e.spec.ts --noStackTrace --runInBand", "test:refresh": "cross-env TEST_MODE=auth jest --testPathPattern refresh.e2e.spec.ts --noStackTrace --runInBand", "test:watch": "jest --watch", @@ -28,7 +27,7 @@ "start:migrate:dev": "npm run prisma:migrate:reset && npm run prisma:migrate && npm run start:dev" }, "engines": { - "node": "20.0.0" + "node": ">=20.0.0" }, "dependencies": { "@nestjs/common": "^10.3.3", diff --git a/src/app.controller.ts b/src/app.controller.ts index 2ea27e9..06b88e8 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,11 +1,13 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { SkipAuth } from './lib/shared/skipAuth.decorator'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} + @SkipAuth() @Get() getHello(): string { return this.appService.getHello(); diff --git a/src/app.module.ts b/src/app.module.ts index 1a80393..03efe6c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,17 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; import { AlbumModule } from './album/album.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ArtistModule } from './artist/artist.module'; +import { AuthGuard } from './auth/auth.guard'; +import { AuthModule } from './auth/auth.module'; import { FavoriteModule } from './favorite/favorite.module'; +import { LoggingInterceptor } from './logging/logging.interceptor'; +import { LoggingService } from './logging/logging.service'; import { PrismaModule } from './prisma/prisma.module'; import { TrackModule } from './track/track.module'; import { UserModule } from './user/user.module'; @@ -19,8 +25,21 @@ import { UserModule } from './user/user.module'; FavoriteModule, ConfigModule.forRoot(), PrismaModule, + AuthModule, + JwtModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + LoggingService, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: LoggingInterceptor, + }, + ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..3184ce1 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + Controller, + Post, + Req, + UnauthorizedException, +} from '@nestjs/common'; + +import { AuthService } from './auth.service'; +import { CreateAuthDto } from './dto/create-auth.dto'; +import { RefreshAuthDto } from './dto/refresh-auth.dto'; +import { errorMessage } from '../lib/const/const'; +import { SkipAuth } from '../lib/shared/skipAuth.decorator'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @SkipAuth() + @Post('signup') + async signUp(@Body() createAuthDto: CreateAuthDto) { + return this.authService.signUp(createAuthDto); + } + + @SkipAuth() + @Post('login') + async login(@Body() createAuthDto: CreateAuthDto) { + return this.authService.login(createAuthDto); + } + + @SkipAuth() + @Post('refresh') + async refresh( + @Body() { userId, login }: RefreshAuthDto, + @Req() req: Request, + ) { + if (!('token' in req)) { + throw new UnauthorizedException(errorMessage.WRONG_REFRESH_TOKEN); + } + + return this.authService.refreshToken({ + userId, + login, + refreshToken: req.token as string, + }); + } +} diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts new file mode 100644 index 0000000..46b5ad0 --- /dev/null +++ b/src/auth/auth.guard.ts @@ -0,0 +1,68 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; + +import { IS_SKIP_AUTH_KEY, jwtConstants } from '../lib/const/const'; +import { LoggingService } from '../logging/logging.service'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly reflector: Reflector, + private readonly loggingService: LoggingService, + ) {} + + async canActivate(context: ExecutionContext) { + if (this.isSkipAuth(context)) { + return true; + } + + const ctx = context.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + const token = this.extractTokenFromHeader(request); + + if (!token) { + response.on('close', () => { + this.loggingService.logError(context, new Error('unauthorized')); + }); + throw new UnauthorizedException(); + } + + try { + await this.jwtService.verifyAsync(token, { + secret: jwtConstants.ACCESS_SECRET, + }); + + request.token = token; + } catch (e) { + response.on('close', () => { + this.loggingService.logError(context, new Error('unauthorized')); + }); + throw new UnauthorizedException(); + } + + return true; + } + + private isSkipAuth(context: ExecutionContext): boolean { + return this.reflector.getAllAndOverride(IS_SKIP_AUTH_KEY, [ + context.getHandler(), + context.getClass(), + ]); + } + + private extractTokenFromHeader(request: Request): string | undefined { + if (!('authorization' in request.headers)) return undefined; + + const authorization = request.headers.authorization as string | undefined; + const [type, token] = authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..bec767a --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; + +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule, JwtModule], + controllers: [AuthController], + providers: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..5073590 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,109 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; + +import { CreateAuthDto } from './dto/create-auth.dto'; +import { CRYPT_SALT, errorMessage, jwtConstants } from '../lib/const/const'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AuthService { + constructor( + private readonly prismaService: PrismaService, + private readonly jwtService: JwtService, + ) {} + + async signUp({ login, password }: CreateAuthDto) { + // Check if user already exists + const user = await this.prismaService.user.findFirst({ + where: { login }, + }); + + if (user) { + throw new ConflictException(errorMessage.USER_ALREADY_EXISTS); + } + + // hash the password + const hash = await bcrypt.hash(password, CRYPT_SALT); + + // save user in the database + const createdUser = await this.prismaService.user.create({ + data: { login, password: hash }, + }); + + const payload = { userId: createdUser.id, login: createdUser.login }; + const tokens = await this.genTokens(payload); + return { ...tokens, id: createdUser.id }; + } + + async login({ login, password }: CreateAuthDto) { + const user = await this.prismaService.user.findFirst({ + where: { login }, + }); + + if (!user) { + throw new NotFoundException(errorMessage.USER_NOT_FOUND); + } + + const isSamePassword = await bcrypt.compare(password, user.password); + if (!isSamePassword) { + throw new ForbiddenException(errorMessage.INVALID_PASSWORD); + } + + const payload = { userId: user.id, login: user.login }; + const tokens = await this.genTokens(payload); + return { ...tokens, id: user.id }; + } + + async refreshToken({ + userId, + login, + refreshToken, + }: { + userId: string; + login: string; + refreshToken: string; + }) { + try { + await this.jwtService.verifyAsync(refreshToken, { + secret: jwtConstants.REFRESH_SECRET, + }); + + const payload = { userId, login }; + const tokens = await this.genTokens(payload); + return { ...tokens, id: userId }; + } catch (e) { + throw new BadRequestException(errorMessage.WRONG_REFRESH_TOKEN); + } + } + + private async genTokens(payload: { userId: string; login: string }) { + // Create access token + const waitAccessToken = this.jwtService.sign(payload, { + secret: jwtConstants.ACCESS_SECRET, + expiresIn: jwtConstants.ACCESS_EXPIRES_IN, + }); + + // Create refresh token + const waitRefreshToken = this.jwtService.sign(payload, { + secret: jwtConstants.REFRESH_SECRET, + expiresIn: jwtConstants.REFRESH_EXPIRES_IN, + }); + + const [accessToken, refreshToken] = await Promise.all([ + waitAccessToken, + waitRefreshToken, + ]); + + return { + accessToken, + refreshToken, + }; + } +} diff --git a/src/auth/dto/create-auth.dto.ts b/src/auth/dto/create-auth.dto.ts new file mode 100644 index 0000000..20ac82b --- /dev/null +++ b/src/auth/dto/create-auth.dto.ts @@ -0,0 +1,3 @@ +import { CreateUserDto } from '../../user/dto/create-user.dto'; + +export class CreateAuthDto extends CreateUserDto {} diff --git a/src/auth/dto/refresh-auth.dto.ts b/src/auth/dto/refresh-auth.dto.ts new file mode 100644 index 0000000..60bbbda --- /dev/null +++ b/src/auth/dto/refresh-auth.dto.ts @@ -0,0 +1,9 @@ +import { IsString, IsUUID } from 'class-validator'; + +export class RefreshAuthDto { + @IsUUID() + userId: string; + + @IsString() + login: string; +} diff --git a/src/auth/dto/update-auth.dto.ts b/src/auth/dto/update-auth.dto.ts new file mode 100644 index 0000000..ca407ce --- /dev/null +++ b/src/auth/dto/update-auth.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateAuthDto } from './create-auth.dto'; + +export class UpdateAuthDto extends PartialType(CreateAuthDto) {} diff --git a/src/auth/entities/auth.entity.ts b/src/auth/entities/auth.entity.ts new file mode 100644 index 0000000..15f15a8 --- /dev/null +++ b/src/auth/entities/auth.entity.ts @@ -0,0 +1 @@ +export class Auth {} diff --git a/src/lib/const/const.ts b/src/lib/const/const.ts index 3e0719b..095e744 100644 --- a/src/lib/const/const.ts +++ b/src/lib/const/const.ts @@ -6,6 +6,15 @@ export const errorMessage = { ARTIST_NOT_FOUND: 'Artist not found!', ALBUM_NOT_FOUND: 'Album not found!', INVALID_PASSWORD: 'Wrong password provided!', + USER_ALREADY_EXISTS: 'The user is already exists', + WRONG_REFRESH_TOKEN: 'The refresh token is wrong or does not exists', +} as const; + +export const jwtConstants = { + ACCESS_SECRET: process.env.JWT_SECRET_KEY, + REFRESH_SECRET: process.env.JWT_SECRET_KEY, + ACCESS_EXPIRES_IN: process.env.TOKEN_EXPIRE_TIME, + REFRESH_EXPIRES_IN: process.env.TOKEN_REFRESH_EXPIRE_TIME, } as const; export const SWAGGER_CONFIG = new DocumentBuilder() @@ -16,3 +25,9 @@ export const SWAGGER_CONFIG = new DocumentBuilder() .build(); export const FAVS_TABLE_ID = 'favs'; +export const CRYPT_SALT = Number(process.env.CRYPT_SALT); +export const IS_SKIP_AUTH_KEY = 'isSkipAuth'; +export const LOGS_FILE_NAME = 'log.txt'; +export const LOGS_ERROR_FILE_NAME = 'logError.txt'; +export const LOGS_MAX_FILE_SIZE = + Number(process.env.LOGS_MAX_FILE_SIZE_KB) * 1000; diff --git a/src/lib/shared/skipAuth.decorator.ts b/src/lib/shared/skipAuth.decorator.ts new file mode 100644 index 0000000..a4a6334 --- /dev/null +++ b/src/lib/shared/skipAuth.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +import { IS_SKIP_AUTH_KEY } from '../const/const'; + +export const SkipAuth = () => SetMetadata(IS_SKIP_AUTH_KEY, true); diff --git a/src/logging/logging.controller.ts b/src/logging/logging.controller.ts new file mode 100644 index 0000000..70507a2 --- /dev/null +++ b/src/logging/logging.controller.ts @@ -0,0 +1,8 @@ +import { Controller } from '@nestjs/common'; + +import { LoggingService } from './logging.service'; + +@Controller('logging') +export class LoggingController { + constructor(private readonly loggingService: LoggingService) {} +} diff --git a/src/logging/logging.interceptor.ts b/src/logging/logging.interceptor.ts new file mode 100644 index 0000000..10dea87 --- /dev/null +++ b/src/logging/logging.interceptor.ts @@ -0,0 +1,22 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; + +import { LoggingService } from './logging.service'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + constructor(private readonly loggingService: LoggingService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next + .handle() + .pipe( + tap((responseBody) => this.loggingService.log(context, responseBody)), + ); + } +} diff --git a/src/logging/logging.module.ts b/src/logging/logging.module.ts new file mode 100644 index 0000000..6fdb056 --- /dev/null +++ b/src/logging/logging.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { LoggingController } from './logging.controller'; +import { LoggingService } from './logging.service'; + +@Module({ + controllers: [LoggingController], + providers: [LoggingService], +}) +export class LoggingModule {} diff --git a/src/logging/logging.service.ts b/src/logging/logging.service.ts new file mode 100644 index 0000000..09e39ba --- /dev/null +++ b/src/logging/logging.service.ts @@ -0,0 +1,132 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { ExecutionContext, Injectable } from '@nestjs/common'; + +import { + LOGS_ERROR_FILE_NAME, + LOGS_FILE_NAME, + LOGS_MAX_FILE_SIZE, +} from '../lib/const/const'; + +@Injectable() +export class LoggingService { + private logsFileNameTemp = LOGS_FILE_NAME; + + private logsFileVersion = 0; + + private logsErrorFileNameTemp = LOGS_ERROR_FILE_NAME; + + private logsErrorFileVersion = 0; + + constructor() { + process.on('uncaughtException', (err) => { + const errorMsg = `Error occurred! (${err.message})`; + console.log(errorMsg); + void this.writeLogFile(errorMsg); + }); + + process.on('unhandledRejection', (err) => { + const errorMsg = `Error occurred! (${(err as Error).message})`; + console.log(errorMsg); + void this.writeLogFile(errorMsg); + }); + } + + log(context: ExecutionContext, responseBody: Record) { + const { date, statusCode, body, url, query } = this.prepareCtx( + context, + responseBody, + ); + const logStr = `${date}: Url: ${url}, Query: ${query}, Body: ${body}, Status code: ${statusCode}\n`; + + process.stdout.write(logStr); + void this.writeLogFile(logStr); + } + + logError(context: ExecutionContext, error: Error) { + const { date, query, url, statusCode } = this.prepareCtx(context); + const logStr = `${date} Url: ${url}, Query: ${query}, Body: ${null}, Status code: ${statusCode} Error message: (${error.message})\n`; + + process.stdout.write(logStr); + void this.writeErrorLogsFile(logStr); + } + + private async writeLogFile(logStr: string) { + const { fileName, version } = this.incrFileVersion( + path.resolve(this.logsFileNameTemp), + this.logsFileNameTemp, + this.logsFileVersion, + ); + this.logsFileNameTemp = fileName; + this.logsFileVersion = version; + + fs.createWriteStream(path.resolve(this.logsFileNameTemp), { flags: 'a' }) + .on('error', (e) => { + console.log(e.message); + }) + .write(`${logStr}\n`); + } + + private writeErrorLogsFile(logStr: string) { + const logsErrorFilePath = path.resolve(this.logsErrorFileNameTemp); + + const { fileName, version } = this.incrFileVersion( + logsErrorFilePath, + this.logsErrorFileNameTemp, + this.logsErrorFileVersion, + ); + this.logsErrorFileNameTemp = fileName; + this.logsErrorFileVersion = version; + + fs.createWriteStream(logsErrorFilePath, { flags: 'a' }) + .on('error', (e) => { + console.log(e.message); + }) + .write(`${logStr}\n`); + } + + private incrFileVersion( + logsFilePath: string, + fileName: string, + version: number, + ) { + try { + if (fs.statSync(logsFilePath).size >= LOGS_MAX_FILE_SIZE) { + const name = fileName.slice(0, fileName.lastIndexOf('.')); + const newVersion = version + 1; + const fileNameNoDigits = name.replaceAll(/\d/g, ''); + + const newFileName = `${fileNameNoDigits}${newVersion}.txt`; + return { fileName: newFileName, version: newVersion }; + } + } catch (e) { + // + } + + return { fileName, version }; + } + + private prepareCtx( + context: ExecutionContext, + responseBody: Record = {}, + ) { + const res = context.switchToHttp().getResponse(); + const { + req: { + url, + _parsedUrl: { query }, + }, + statusCode, + } = res; + const date = this.getFormattedTime(); + const urlStr = url.slice(0, url.indexOf('?')) || '/'; + const body = JSON.stringify(responseBody); + + return { date, url: urlStr, body, statusCode, query }; + } + + private getFormattedTime() { + return `${new Date().toLocaleTimeString('en-EU', { timeZone: 'Europe/Kyiv' })}`; + } +} diff --git a/tsconfig.json b/tsconfig.json index 4e9062c..d4931b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./",