diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..decda35 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Nest Framework", + "args": ["${workspaceFolder}/src/main.ts"], + "runtimeArgs": [ + "--nolazy", + "-r", + "ts-node/register", + "-r", + "tsconfig-paths/register" + ], + "sourceMaps": true, + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceRoot}", + "console": "integratedTerminal", + "autoAttachChildProcesses": true + } + ] +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index afc1b7a..c99bbdc 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,9 +1,11 @@ -import { Controller, Post, Body } from '@nestjs/common'; +import { Controller, Post, Body, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { CreateUserDto } from 'src/user/dto'; import { LoginAuthDto } from './dto'; +import { Auth, GetUser } from './decorators'; +import { User } from 'src/user/entities/user.entity'; @ApiTags('auth') @Controller('auth') @@ -19,4 +21,10 @@ export class AuthController { loginAuth(@Body() loginAuthDto: LoginAuthDto) { return this.authService.loginAuth(loginAuthDto); } + + @Get('check-status') + @Auth() + checkAuthStatus(@GetUser() user: User) { + return this.authService.checkAuthStatus(user); + } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 2e54f79..017332e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -13,7 +13,7 @@ import * as bcrypt from 'bcrypt'; import { CreateUserDto } from 'src/user/dto'; import { User } from 'src/user/entities/user.entity'; -import { MessageHandler } from 'src/utils/enums/message.handler'; +import { MessageHandler } from 'src/shared/enums'; import { LoginAuthDto } from './dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; @@ -30,7 +30,6 @@ export class AuthService { async createAuth(createUserDto: CreateUserDto) { try { const { password, ...userData } = createUserDto; - const user = this.userRepository.create({ ...userData, password: bcrypt.hashSync(password, 10), @@ -40,7 +39,7 @@ export class AuthService { delete user.password; return { ...user, - token: this.getJwtToken({ email: user.email }), + token: this.getJwtToken({ id: user.id }), }; } catch (error) { if (error.code === '23505') @@ -58,7 +57,7 @@ export class AuthService { const { password, email } = loginUserDto; const user = await this.userRepository.findOne({ where: { email }, - select: { email: true, password: true }, + select: { email: true, password: true, id: true }, }); if (!user) @@ -67,9 +66,18 @@ export class AuthService { if (!bcrypt.compareSync(password, user.password)) throw new UnauthorizedException(MessageHandler.UNAUTHORIZED_CREDENTIALS); + delete user.id; + + return { + ...user, + token: this.getJwtToken({ id: user.id }), + }; + } + + checkAuthStatus(user: User) { return { ...user, - token: this.getJwtToken({ email: user.email }), + token: this.getJwtToken({ id: user.id }), }; } diff --git a/src/auth/decorators/auth.decorator.ts b/src/auth/decorators/auth.decorator.ts new file mode 100644 index 0000000..4bdbbd5 --- /dev/null +++ b/src/auth/decorators/auth.decorator.ts @@ -0,0 +1,13 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import { RoleProtected } from 'src/auth/decorators'; +import { ValidRoles } from 'src/shared/enums'; +import { UserRoleGuard } from '../guards/user-role.guard'; + +export function Auth(...roles: ValidRoles[]) { + return applyDecorators( + RoleProtected(...roles), + UseGuards(AuthGuard(), UserRoleGuard), + ); +} diff --git a/src/auth/decorators/get-user.decorator.ts b/src/auth/decorators/get-user.decorator.ts new file mode 100644 index 0000000..537b323 --- /dev/null +++ b/src/auth/decorators/get-user.decorator.ts @@ -0,0 +1,19 @@ +import { + createParamDecorator, + ExecutionContext, + InternalServerErrorException, +} from '@nestjs/common'; + +import { MessageHandler } from 'src/shared/enums'; + +export const GetUser = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + const user = req.user; + + if (!user) + throw new InternalServerErrorException(MessageHandler.USER_NOT_FOUND); + + return !data ? user : user[data]; + }, +); diff --git a/src/auth/decorators/index.ts b/src/auth/decorators/index.ts new file mode 100644 index 0000000..27757f8 --- /dev/null +++ b/src/auth/decorators/index.ts @@ -0,0 +1,3 @@ +export { Auth } from './auth.decorator'; +export { RoleProtected } from './role-protected.decorator'; +export { GetUser } from './get-user.decorator'; diff --git a/src/auth/decorators/role-protected.decorator.ts b/src/auth/decorators/role-protected.decorator.ts new file mode 100644 index 0000000..9507791 --- /dev/null +++ b/src/auth/decorators/role-protected.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { MetaRoles, ValidRoles } from 'src/shared/enums'; + +export const RoleProtected = (...args: ValidRoles[]) => + SetMetadata(MetaRoles.ROLES, args); diff --git a/src/auth/dto/login-auth.dto.ts b/src/auth/dto/login-auth.dto.ts index 483a5aa..7b11da7 100644 --- a/src/auth/dto/login-auth.dto.ts +++ b/src/auth/dto/login-auth.dto.ts @@ -7,8 +7,8 @@ import { MinLength, } from 'class-validator'; -import { validatePassword } from 'src/utils/actions/validations'; -import { MessageHandler } from 'src/utils/enums/message.handler'; +import { validatePassword } from 'src/shared/actions/validations'; +import { MessageHandler } from 'src/shared/enums'; export class LoginAuthDto { @ApiProperty() diff --git a/src/auth/guards/user-role.guard.ts b/src/auth/guards/user-role.guard.ts new file mode 100644 index 0000000..9d233bc --- /dev/null +++ b/src/auth/guards/user-role.guard.ts @@ -0,0 +1,41 @@ +import { Reflector } from '@nestjs/core'; +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; + +import { MessageHandler, MetaRoles } from 'src/shared/enums'; +import { User } from 'src/user/entities/user.entity'; + +@Injectable() +export class UserRoleGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + const validRoles: string[] = this.reflector.get( + MetaRoles.ROLES, + context.getHandler(), + ); + + if (!validRoles) return true; + if (validRoles.length === 0) return true; + + const req = context.switchToHttp().getRequest(); + const user = req.user as User; + + if (!user) throw new BadRequestException(MessageHandler.USER_NOT_FOUND); + + for (const role of user.roles) { + if (validRoles.includes(role)) { + return true; + } + } + + throw new ForbiddenException(MessageHandler.USER_INVALID_ROLE); + } +} diff --git a/src/auth/interfaces/jwt-payload.interface.ts b/src/auth/interfaces/jwt-payload.interface.ts index 9da99ad..083d6a9 100644 --- a/src/auth/interfaces/jwt-payload.interface.ts +++ b/src/auth/interfaces/jwt-payload.interface.ts @@ -1,3 +1,3 @@ export interface JwtPayload { - email: string; + id: string; } diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 3df20b2..d5f4abe 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -7,7 +7,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { User } from 'src/user/entities/user.entity'; -import { MessageHandler } from 'src/utils/enums/message.handler'; +import { MessageHandler, ValidState } from 'src/shared/enums'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -24,12 +24,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: JwtPayload): Promise { - const { email } = payload; - const user = await this.userRepository.findOneBy({ email }); + const { id } = payload; + const user = await this.userRepository.findOneBy({ id }); if (!user) throw new UnauthorizedException(MessageHandler.UNAUTHORIZED_TOKEN); - if (!user.active) + if (user.state !== ValidState.ACTIVE) throw new UnauthorizedException(MessageHandler.UNAUTHORIZED_USER); return user; diff --git a/src/products/entities/product.entity.ts b/src/products/entities/product.entity.ts index 1a90a0f..6452dba 100644 --- a/src/products/entities/product.entity.ts +++ b/src/products/entities/product.entity.ts @@ -1,4 +1,5 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from 'src/user/entities/user.entity'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Product { @@ -25,4 +26,7 @@ export class Product { @Column('bool', { default: true }) active: boolean; + + @ManyToOne(() => User, (user) => user.product, { eager: true }) + user: User; } diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 3b23a86..c3a84fa 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -16,6 +16,8 @@ import { import { ProductsService } from './products.service'; import { CreateProductDto, UpdateProductDto } from 'src/products/dto'; +import { Auth, GetUser } from 'src/auth/decorators'; +import { User } from 'src/user/entities/user.entity'; @ApiTags('products') @Controller('products') @@ -23,13 +25,14 @@ export class ProductsController { constructor(private readonly productsService: ProductsService) {} @Post() + @Auth() @ApiOperation({ summary: 'Create a new product' }) @ApiCreatedResponse({ description: 'The record has been successfully created.', }) @ApiForbiddenResponse({ description: 'Forbidden.' }) - create(@Body() createProductDto: CreateProductDto) { - return this.productsService.create(createProductDto); + create(@Body() createProductDto: CreateProductDto, @GetUser() user: User) { + return this.productsService.create(createProductDto, user); } @Get() diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 94912b9..661921d 100644 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from 'src/auth/auth.module'; + import { ProductsService } from './products.service'; import { ProductsController } from './products.controller'; import { Product } from './entities/product.entity'; @@ -8,6 +10,6 @@ import { Product } from './entities/product.entity'; @Module({ controllers: [ProductsController], providers: [ProductsService], - imports: [TypeOrmModule.forFeature([Product])], + imports: [TypeOrmModule.forFeature([Product]), AuthModule], }) export class ProductsModule {} diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 0c025a9..b4f23d0 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -11,7 +11,8 @@ import { Repository } from 'typeorm'; import { CreateProductDto, UpdateProductDto } from 'src/products/dto'; import { Product } from './entities/product.entity'; -import { MessageHandler } from 'src/utils/enums/message.handler'; +import { MessageHandler } from 'src/shared/enums'; +import { User } from 'src/user/entities/user.entity'; @Injectable() export class ProductsService { @@ -22,9 +23,10 @@ export class ProductsService { private readonly logger = new Logger('ProductsService'); - async create(createProductDto: CreateProductDto) { + async create(createProductDto: CreateProductDto, user: User) { try { const product = this.productRepository.create(createProductDto); + product.user = user; await this.productRepository.save(product); return product; } catch (error) { @@ -47,7 +49,6 @@ export class ProductsService { const product = await this.productRepository.findOneBy({ id }); if (!product) throw new NotFoundException(`Product with id ${id} not found`); - console.log(product); return product; } diff --git a/src/utils/actions/validations.ts b/src/shared/actions/validations.ts similarity index 100% rename from src/utils/actions/validations.ts rename to src/shared/actions/validations.ts diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts new file mode 100644 index 0000000..0d0d7eb --- /dev/null +++ b/src/shared/decorators/index.ts @@ -0,0 +1 @@ +export { RawHeaders } from './raw-headers.decorator'; diff --git a/src/shared/decorators/raw-headers.decorator.ts b/src/shared/decorators/raw-headers.decorator.ts new file mode 100644 index 0000000..1a0a7fa --- /dev/null +++ b/src/shared/decorators/raw-headers.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const RawHeaders = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + return req.rawHeaders; + }, +); + +//uso @RawHeader() rawHeaders: string[] +// best use @Headers() headers: IncomingHttpHeaders diff --git a/src/shared/enums/index.ts b/src/shared/enums/index.ts new file mode 100644 index 0000000..5f8dd26 --- /dev/null +++ b/src/shared/enums/index.ts @@ -0,0 +1,4 @@ +export { MessageHandler } from './message.handler'; +export { MetaRoles } from './meta.roles'; +export { ValidRoles } from './valid.roles'; +export { ValidState } from './valid.state'; diff --git a/src/utils/enums/message.handler.ts b/src/shared/enums/message.handler.ts similarity index 55% rename from src/utils/enums/message.handler.ts rename to src/shared/enums/message.handler.ts index 67da933..807b2c3 100644 --- a/src/utils/enums/message.handler.ts +++ b/src/shared/enums/message.handler.ts @@ -1,7 +1,17 @@ export enum MessageHandler { PASSWORD_INVALID = 'The password must have a Uppercase, lowercase letter and a number', + UNEXPECTED_ERROR = 'Unexpected error, check server logs', + UNAUTHORIZED_CREDENTIALS = 'Email or password are not valid', UNAUTHORIZED_TOKEN = 'Token is not valid', UNAUTHORIZED_USER = 'User is inactive, contact support', + + USERS_NOT_FOUND = 'Users not found', + USER_NOT_FOUND = 'User not found', + USER_INVALID_ROLE = 'User does not have permissions', + USER_INVALID_STATUS = 'User does not have valid status', + USER_INACTIVE = 'User is inactive', + + EMAIL_ALREADY_EXIST = 'Email is all ready exist', } diff --git a/src/shared/enums/meta.roles.ts b/src/shared/enums/meta.roles.ts new file mode 100644 index 0000000..8df2495 --- /dev/null +++ b/src/shared/enums/meta.roles.ts @@ -0,0 +1,3 @@ +export enum MetaRoles { + ROLES = 'roles', +} diff --git a/src/shared/enums/valid.roles.ts b/src/shared/enums/valid.roles.ts new file mode 100644 index 0000000..c007a70 --- /dev/null +++ b/src/shared/enums/valid.roles.ts @@ -0,0 +1,4 @@ +export enum ValidRoles { + USER = 'User', + ADMIN = 'Admin', +} diff --git a/src/shared/enums/valid.state.ts b/src/shared/enums/valid.state.ts new file mode 100644 index 0000000..ae88336 --- /dev/null +++ b/src/shared/enums/valid.state.ts @@ -0,0 +1,6 @@ +export enum ValidState { + ACTIVE = 'Active', + PREREGISTER = 'Preregister', + INACTIVE = 'Inactive', + SUSPENDED = 'Suspended', +} diff --git a/src/user/dto/create-user.dto.ts b/src/user/dto/create-user.dto.ts index dce2156..7bce58a 100644 --- a/src/user/dto/create-user.dto.ts +++ b/src/user/dto/create-user.dto.ts @@ -1,13 +1,15 @@ import { ApiProperty } from '@nestjs/swagger'; import { + IsArray, IsEmail, IsString, Matches, MaxLength, MinLength, } from 'class-validator'; -import { validatePassword } from 'src/utils/actions/validations'; -import { MessageHandler } from 'src/utils/enums/message.handler'; + +import { validatePassword } from 'src/shared/actions/validations'; +import { MessageHandler } from 'src/shared/enums'; export class CreateUserDto { @ApiProperty() @@ -32,4 +34,12 @@ export class CreateUserDto { @IsString() @MinLength(1) lastname: string; + + @ApiProperty() + @IsString() + state: string; + + @ApiProperty() + @IsArray() + roles: string[]; } diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 481b28a..018e88c 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -3,16 +3,20 @@ import { BeforeUpdate, Column, Entity, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { Exclude } from 'class-transformer'; -import { UserRoles } from 'src/utils/enums/user.types'; +import { Product } from 'src/products/entities/product.entity'; @Entity('user') export class User { + @Exclude({ toPlainOnly: true }) @PrimaryGeneratedColumn('uuid') id: string; + @Exclude({ toPlainOnly: true }) @Column('text', { unique: true }) email: string; @@ -21,21 +25,24 @@ export class User { }) password: string; + @Exclude({ toPlainOnly: true }) @Column('text') name: string; + @Exclude({ toPlainOnly: true }) @Column('text') lastname: string; - @Column('bool', { default: true }) - active: boolean; + @Column('text') + state: string; - @Column('text', { - array: true, - default: [UserRoles.USER], - }) + @Exclude({ toPlainOnly: true }) + @Column('text', { array: true }) roles: string[]; + @OneToMany(() => Product, (product) => product.user) + product: Product; + @BeforeInsert() checkFieldBeforeInsert() { this.email = this.email.toLocaleLowerCase().trim(); diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 209064e..ee117e7 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,44 +1,44 @@ import { Controller, Get, - Post, Body, Patch, Param, Delete, + ClassSerializerInterceptor, + UseInterceptors, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { UserService } from './user.service'; -import { CreateUserDto, UpdateUserDto } from 'src/user/dto'; +import { UpdateUserDto } from 'src/user/dto'; +import { Auth } from '../auth/decorators'; +import { ValidRoles } from 'src/shared/enums'; @ApiTags('user') @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} - @Post() - create(@Body() createUserDto: CreateUserDto) { - return this.userService.create(createUserDto); - } - @Get() + @Auth(ValidRoles.ADMIN) findAll() { return this.userService.findAll(); } @Get(':id') findOne(@Param('id') id: string) { - return this.userService.findOne(+id); + return this.userService.findOne(id); } @Patch(':id') update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { - return this.userService.update(+id, updateUserDto); + return this.userService.update(id, updateUserDto); } @Delete(':id') + @UseInterceptors(ClassSerializerInterceptor) remove(@Param('id') id: string) { - return this.userService.remove(+id); + return this.userService.remove(id); } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index b333022..27d9b98 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PassportModule } from '@nestjs/passport'; import { UserService } from './user.service'; import { UserController } from './user.controller'; @@ -8,6 +9,9 @@ import { User } from './entities/user.entity'; @Module({ controllers: [UserController], providers: [UserService], - imports: [TypeOrmModule.forFeature([User])], + imports: [ + TypeOrmModule.forFeature([User]), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], }) export class UserModule {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 5c9f359..6dea36d 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,25 +1,56 @@ -import { Injectable } from '@nestjs/common'; -import { CreateUserDto, UpdateUserDto } from 'src/user/dto'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { UpdateUserDto } from 'src/user/dto'; +import { User } from './entities/user.entity'; +import { MessageHandler, ValidState } from 'src/shared/enums'; @Injectable() export class UserService { - create(createUserDto: CreateUserDto) { - return 'This action adds a new user'; - } + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} - findAll() { - return `This action returns all user`; + async findAll() { + return this.userRepository.find(); } - findOne(id: number) { - return `This action returns a #${id} user`; + async findOne(id: string) { + const user = await this.userRepository.findOneBy({ id }); + if (!user) throw new NotFoundException(MessageHandler.USERS_NOT_FOUND); + + return user; } - update(id: number, updateUserDto: UpdateUserDto) { - return `This action updates a #${id} user`; + async update(id: string, updateUserDto: UpdateUserDto) { + // TODO: Only admin can change role and status + + const user = await this.userRepository.findOne({ where: { id: id } }); + if (user.state !== ValidState.ACTIVE) + throw new BadRequestException(MessageHandler.USER_INVALID_STATUS); + + if (updateUserDto.email === user.email) + throw new BadRequestException(MessageHandler.EMAIL_ALREADY_EXIST); + + this.userRepository.merge(user, updateUserDto); + return this.userRepository.save(user); } - remove(id: number) { - return `This action removes a #${id} user`; + async remove(id: string) { + const user = await this.userRepository.findOne({ where: { id: id } }); + + if (user.state === ValidState.INACTIVE) + throw new BadRequestException(MessageHandler.USER_INACTIVE); + + user.state = ValidState.INACTIVE; + this.userRepository.save(user); + + return user; } } diff --git a/src/utils/enums/user.types.ts b/src/utils/enums/user.types.ts deleted file mode 100644 index f13e63e..0000000 --- a/src/utils/enums/user.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum UserRoles { - USER = 'user', - ADMIN = 'admin', -} diff --git a/src/utils/validation.pipes.ts b/src/utils/validation.pipes.ts deleted file mode 100644 index 1353581..0000000 --- a/src/utils/validation.pipes.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common'; - -@Injectable() -export class ValidationPipe implements PipeTransform { - transform(value: any, metadata: ArgumentMetadata) { - return value; - } -}