diff --git a/backend/package.json b/backend/package.json index c4ac202..0fc0d1f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.4.0", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "mongoose": "^8.5.4", @@ -41,6 +42,7 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..43f42d0 --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { LoginCredentialsDto } from './dto/login-credentials.dto'; +import { ApiTags } from '@nestjs/swagger'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) { } + + @Post('credentials') + async loginWithCredentials( + @Body() loginWithCredentials: LoginCredentialsDto, + ) { + return await this.authService.login(loginWithCredentials); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 26e9aa9..341aef9 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,13 +1,15 @@ import { Module } from '@nestjs/common'; -import { JwtStrategy } from './strategies/jwt.strategy'; - import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { UserModule } from 'src/user/user.module'; + +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +import { UserModule } from '../user/user.module'; @Module({ - providers: [JwtStrategy], imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), @@ -23,9 +25,10 @@ import { UserModule } from 'src/user/user.module'; }; }, }), - UserModule, ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], exports: [PassportModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..75c2148 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,44 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +import { LoginCredentialsDto } from './dto/login-credentials.dto'; +import { HashAdapter } from '../common/adapters/hash.adapter'; +import { UserService } from '../user/user.service'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; + +@Injectable() +export class AuthService { + constructor( + private readonly userService: UserService, + private readonly jwtService: JwtService, + ) { } + + async login({ email, password }: LoginCredentialsDto) { + const userFound = await this.userService.findOneByEmail(email); + + const hashAdapter = new HashAdapter(); + + const isPasswordMatch = hashAdapter.verifyHash( + password, + userFound.password, + ); + + if (!isPasswordMatch) throw new UnauthorizedException('Incorrect password'); + + const token = this.getJwt({ id: userFound.id }); + + return { + id: userFound._id, + name: `${userFound.firstname} ${userFound.lastname}`, + username: userFound.username, + isAdmin: userFound.isAdmin, + email: userFound.email, + token, + }; + } + + private getJwt(payload: JwtPayload) { + const token = this.jwtService.sign(payload); + return token; + } +} diff --git a/backend/src/auth/dto/login-credentials.dto.ts b/backend/src/auth/dto/login-credentials.dto.ts new file mode 100644 index 0000000..75a6ce1 --- /dev/null +++ b/backend/src/auth/dto/login-credentials.dto.ts @@ -0,0 +1,37 @@ +import { IsEmail, IsString, Length, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginCredentialsDto { + @ApiProperty({ + description: "The user's email address", + example: 'user@example.com', + }) + @IsEmail() + readonly email: string; + + @ApiProperty({ + description: + "The user's password. It must have between 8 and 20 characters, including at least one uppercase letter, one lowercase letter, one number, and one special character.", + example: 'P@ssw0rd!', + minLength: 8, + maxLength: 20, + }) + @IsString() + @Length(8, 20, { + message: 'The password must have between 8 and 20 characters', + }) + @Matches(/(?=.*[A-Z])/, { + message: 'The password must have at least one uppercase letter', + }) + @Matches(/(?=.*[a-z])/, { + message: 'The password must have at least one lowercase letter', + }) + @Matches(/(?=.*\d)/, { + message: 'The password must have at least one number', + }) + @Matches(/(?=.*[@$!%*?&#])/, { + message: + 'The password must have at least one special character (for example, @, $, !, %, *, ?, &, #)', + }) + readonly password: string; +} diff --git a/backend/src/common/adapters/hash.adapter.ts b/backend/src/common/adapters/hash.adapter.ts new file mode 100644 index 0000000..dddaae4 --- /dev/null +++ b/backend/src/common/adapters/hash.adapter.ts @@ -0,0 +1,13 @@ +import * as bcrypt from 'bcrypt'; + +export class HashAdapter { + private readonly hashDependency = bcrypt; + + createHash(data: string | Buffer, saltOrRounds: string | number): string { + return this.hashDependency.hashSync(data, saltOrRounds); + } + + verifyHash(data: string | Buffer, encrypted: string): boolean { + return this.hashDependency.compareSync(data, encrypted); + } +} diff --git a/backend/src/user/dto/create-user.dto.ts b/backend/src/user/dto/create-user.dto.ts index d1490b4..20f0500 100644 --- a/backend/src/user/dto/create-user.dto.ts +++ b/backend/src/user/dto/create-user.dto.ts @@ -1,36 +1,69 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, + Length, + Matches, } from 'class-validator'; -import { ApiProperty, ApiBody } from '@nestjs/swagger'; + export class CreateUserDto { @IsOptional() @IsString() firstname: string; + @IsOptional() @IsString() lastname: string; + @IsNotEmpty() @IsString() @IsEmail() email: string; - @IsNotEmpty() + + @ApiProperty({ + description: + "The user's password. It must have between 8 and 20 characters, including at least one uppercase letter, one lowercase letter, one number, and one special character.", + example: 'P@ssw0rd!', + minLength: 8, + maxLength: 20, + }) @IsString() + @Length(8, 20, { + message: 'The password must have between 8 and 20 characters', + }) + @Matches(/(?=.*[A-Z])/, { + message: 'The password must have at least one uppercase letter', + }) + @Matches(/(?=.*[a-z])/, { + message: 'The password must have at least one lowercase letter', + }) + @Matches(/(?=.*\d)/, { + message: 'The password must have at least one number', + }) + @Matches(/(?=.*[@$!%*?&#])/, { + message: + 'The password must have at least one special character (for example, @, $, !, %, *, ?, &, #)', + }) password: string; + @IsOptional() @IsString() country: string; + @IsOptional() tags: [string]; + @IsNotEmpty() @IsBoolean() isAdmin: boolean; + @IsOptional() @IsString() description: string; + @IsOptional() @IsString() imageUrl: string; diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 6236548..2541a1a 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + import { UserService } from './user.service'; import { UserController } from './user.controller'; -import { MongooseModule } from '@nestjs/mongoose'; import { UserSchema } from './entities/user.entity'; + @Module({ imports: [MongooseModule.forFeature([{ name: 'User', schema: UserSchema }])], controllers: [UserController], providers: [UserService], - exports: [MongooseModule], + exports: [UserService, MongooseModule], }) export class UserModule {} diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 8b71a9b..2fd4302 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -58,4 +58,13 @@ export class UserService { const res = await this.userModel.findByIdAndDelete(id); return res; } + + async findOneByEmail(email: string) { + const userFound = await this.userModel.findOne({ email }); + + if (!userFound) + throw new NotFoundException(`User with email ${email} not exists`); + + return userFound; + } }