Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ namespace NodeJS {
POSTGRES_DB:string;
POSTGRES_PORT:string;
DATABASE_URL:string;
LOGS_MAX_FILE_SIZE_KB:string;
LOGGING_LEVEL:string;
}
}
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 \"<rootDir>/*.spec.ts\" --noStackTrace --runInBand",
"test": "jest --testMatch \"<rootDir>/*.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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
21 changes: 20 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {}
47 changes: 47 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
68 changes: 68 additions & 0 deletions src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
109 changes: 109 additions & 0 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
3 changes: 3 additions & 0 deletions src/auth/dto/create-auth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { CreateUserDto } from '../../user/dto/create-user.dto';

export class CreateAuthDto extends CreateUserDto {}
9 changes: 9 additions & 0 deletions src/auth/dto/refresh-auth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsString, IsUUID } from 'class-validator';

export class RefreshAuthDto {
@IsUUID()
userId: string;

@IsString()
login: string;
}
5 changes: 5 additions & 0 deletions src/auth/dto/update-auth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';

import { CreateAuthDto } from './create-auth.dto';

export class UpdateAuthDto extends PartialType(CreateAuthDto) {}
1 change: 1 addition & 0 deletions src/auth/entities/auth.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class Auth {}
15 changes: 15 additions & 0 deletions src/lib/const/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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;
5 changes: 5 additions & 0 deletions src/lib/shared/skipAuth.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
8 changes: 8 additions & 0 deletions src/logging/logging.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Controller } from '@nestjs/common';

import { LoggingService } from './logging.service';

@Controller('logging')
export class LoggingController {
constructor(private readonly loggingService: LoggingService) {}
}
Loading