Skip to content

Commit

Permalink
[backend] Implement POST /auth/login (#30)
Browse files Browse the repository at this point in the history
* [backend] Add auth module and passport to backend
- `docker compose exec backend yarn run nest generate resource`
- `docker compose exec backend yarn add @nestjs/passport passport @nestjs/jwt passport-jwt`
- `docker compose exec backend yarn add -D @types/passport-jwt`

* [backend] Implement auth.module
* [backend] Implement auth.service
- Create a new file `backend/src/auth/dto/login.dto.ts`
- Create a new file `backend/src/auth/entity/auth.entity.ts`
* [backend] Implement POST /auth/login
* [backend] Add JwtStrategy as a provider
- Export the UserService from the UserModule
* [backend] Implement JwtAuthGuard
* [backend] Add bcrypt and @types/bcrypt
- `docker compose exec backend yarn add bcrypt`
- `docker compose exec backend yarn add -d @types/bcrypt`

* [backend] Use bcrypt to compare the password in the AuthService
* [backend] Add seed script
* [ci] Add BACKEND_JWT_SECRET to .env file
  • Loading branch information
usatie authored Nov 7, 2023
1 parent c42e152 commit 1d01b8a
Show file tree
Hide file tree
Showing 23 changed files with 880 additions and 8 deletions.
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ NGINX_PORT=
PUBLIC_API_URL=
FRONTEND_PORT=
BACKEND_PORT=
BACKEND_JWT_SECRET=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jobs:
echo "PUBLIC_API_URL=${{ vars.PUBLIC_API_URL }}" >> .env
echo "FRONTEND_PORT=${{ vars.FRONTEND_PORT }}" >> .env
echo "BACKEND_PORT=${{ vars.BACKEND_PORT }}" >> .env
echo "BACKEND_JWT_SECRET=${{ vars.BACKEND_JWT_SECRET }}" >> .env
echo "POSTGRES_USER=${{ vars.POSTGRES_USER }}" >> .env
echo "POSTGRES_PASSWORD=${{ vars.POSTGRES_PASSWORD }}" >> .env
echo "POSTGRES_DB=${{ vars.POSTGRES_DB }}" >> .env
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ NGINX_PORT=4242
PUBLIC_API_URL=http://localhost:4242/api
FRONTEND_PORT=3000
BACKEND_PORT=3000
BACKEND_JWT_SECRET=some_random_secret
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
Expand All @@ -20,6 +21,11 @@ $ make

You can check the application running at http://localhost:4242

## Seed the database
```
docker compose exec backend yarn prisma db seed
```

## How to test
```
$ make test
Expand Down
9 changes: 9 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.1.1",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.14",
"@prisma/client": "5.5.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"nestjs-prisma": "^0.22.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg-promise": "^11.5.4",
"prisma": "^5.5.0",
"reflect-metadata": "^0.1.13",
Expand All @@ -40,9 +44,11 @@
"@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",
"@types/passport-jwt": "^3.0.12",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
Expand Down Expand Up @@ -79,5 +85,8 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
69 changes: 69 additions & 0 deletions backend/prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';

// Initialize Prisma client
const prisma = new PrismaClient();

const roundsOfHashing = 10;

async function main() {
const passwordSusami = await bcrypt.hash('password-susami', roundsOfHashing);
const passwordThara = await bcrypt.hash('password-thara', roundsOfHashing);
const passwordKakiba = await bcrypt.hash('password-kakiba', roundsOfHashing);
const passwordShongou = await bcrypt.hash(
'password-shongou',
roundsOfHashing,
);

const user1 = await prisma.user.upsert({
where: { email: '[email protected]' },
update: {},
create: {
email: '[email protected]',
name: 'Susami',
password: passwordSusami,
},
});

const user2 = await prisma.user.upsert({
where: { email: '[email protected]' },
update: {},
create: {
email: '[email protected]',
name: 'Thara',
password: passwordThara,
},
});

const user3 = await prisma.user.upsert({
where: { email: '[email protected]' },
update: {},
create: {
email: '[email protected]',
name: 'Kakiba',
password: passwordKakiba,
},
});

const user4 = await prisma.user.upsert({
where: { email: '[email protected]' },
update: {},
create: {
email: '[email protected]',
name: 'Shongou',
password: passwordShongou,
},
});

console.log({ user1, user2, user3, user4 });
}

main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
// close Prisma Client at the end
await prisma.$disconnect();
});
3 changes: 2 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';

@Module({
imports: [UserModule, PrismaModule],
imports: [UserModule, PrismaModule, AuthModule],
controllers: [AppController],
providers: [AppService],
})
Expand Down
22 changes: 22 additions & 0 deletions backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';

describe('AuthController', () => {
let controller: AuthController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [AuthService, PrismaService, JwtService],
}).compile();

controller = module.get<AuthController>(AuthController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
17 changes: 17 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthEntity } from './entity/auth.entity';
import { LoginDto } from './dto/login.dto';

@Controller('auth')
@ApiTags('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('login')
@ApiOkResponse({ type: AuthEntity })
login(@Body() { email, password }: LoginDto): Promise<AuthEntity> {
return this.authService.login(email, password);
}
}
27 changes: 27 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';
import { UserModule } from 'src/user/user.module';
import { JwtStrategy } from './jwt.strategy';

export const jwtConstants = {
secret: process.env.JWT_SECRET,
};

@Module({
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '30m' }, // 30 minutes
}),
UserModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
20 changes: 20 additions & 0 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';

describe('AuthService', () => {
let service: AuthService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService, PrismaService, JwtService],
}).compile();

service = module.get<AuthService>(AuthService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
35 changes: 35 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { AuthEntity } from './entity/auth.entity';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}

async login(email: string, password: string): Promise<AuthEntity> {
const user = await this.prisma.user.findUnique({ where: { email } });

if (!user) {
throw new NotFoundException(`No user found for email: ${email}`);
}

const isPasswordValid = await bcrypt.compare(password, user.password);

if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}

return {
accessToken: this.jwtService.sign({ userId: user.id }),
};
}
}
15 changes: 15 additions & 0 deletions backend/src/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';

export class LoginDto {
@IsEmail()
@IsNotEmpty()
@ApiProperty()
email: string;

@IsString()
@IsNotEmpty()
@MinLength(6)
@ApiProperty()
password: string;
}
6 changes: 6 additions & 0 deletions backend/src/auth/entity/auth.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';

export class AuthEntity {
@ApiProperty()
accessToken: string;
}
5 changes: 5 additions & 0 deletions backend/src/auth/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
25 changes: 25 additions & 0 deletions backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { jwtConstants } from './auth.module';
import { UserService } from 'src/user/user.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: jwtConstants.secret,
});
}

async validate(payload: { userId: number }) {
const user = await this.userService.findOne(payload.userId);

if (!user) {
throw new UnauthorizedException();
}

return user;
}
}
1 change: 1 addition & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ async function bootstrap() {
.setTitle('Pong API')
.setDescription('The Pong API description')
.setVersion('0.1')
.addBearerAuth()
.build();

const document = SwaggerModule.createDocument(app, config);
Expand Down
9 changes: 9 additions & 0 deletions backend/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ import {
Delete,
HttpCode,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User as UserModel } from '@prisma/client';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiNoContentResponse,
ApiTags,
} from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';

@Controller('user')
@ApiTags('user')
Expand All @@ -41,12 +44,16 @@ export class UserController {
}

@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserEntity })
async findOne(@Param('id', ParseIntPipe) id: number): Promise<UserEntity> {
return new UserEntity(await this.userService.findOne(id));
}

@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserEntity })
async update(
@Param('id', ParseIntPipe) id: number,
Expand All @@ -57,6 +64,8 @@ export class UserController {

@Delete(':id')
@HttpCode(204)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiNoContentResponse()
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.userService.remove(id);
Expand Down
Loading

0 comments on commit 1d01b8a

Please sign in to comment.