Work on security#8
Conversation
… to accept users credentials compulsorily
There was a problem hiding this comment.
Pull request overview
This PR implements JWT-based authentication and authorization for the expense tracker application. It introduces a complete auth system with user management, password hashing, JWT token generation, and role-based access control to secure transaction and user endpoints.
Changes:
- Added authentication module with JWT strategy, guards, and login endpoint
- Created users module with user registration, CRUD operations, and password hashing
- Enhanced transaction endpoints with ownership validation and admin-only access controls
- Updated database schema to make user fields required (name, email, passwordHash)
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 26 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Added authentication dependencies (bcrypt, passport, jsonwebtoken, @nestjs/passport) |
| src/auth/auth.module.ts | Created authentication module with JWT strategy and passport integration |
| src/auth/auth.service.ts | Implemented login logic with password verification and JWT token generation |
| src/auth/auth.controller.ts | Added login and profile endpoints |
| src/auth/guards/jwt-auth/jwt-auth.guard.ts | Created JWT authentication guard for protecting endpoints |
| src/auth/strategies/jwt.strategy/jwt.strategy.ts | Implemented JWT validation strategy using passport-jwt |
| src/auth/dto/login.dto.ts | Created DTO for login requests with email and password validation |
| src/users/users.module.ts | Created users module with service, repository, and controller |
| src/users/users.service.ts | Implemented user creation with password hashing and admin check |
| src/users/users.controller.ts | Added user CRUD endpoints with admin-only access control |
| src/users/users.repository.ts | Created repository for user database operations |
| src/users/dto/users-dto.ts | Created DTO for user creation with validation |
| src/transactions/transactions.service.ts | Added ownership validation to prevent users from accessing others' transactions |
| src/transactions/transactions.controller.ts | Protected all endpoints with JWT auth and added admin checks for list endpoint |
| src/transactions/dto/create-transaction.dto.ts | Removed unused Length import |
| src/db/schema.ts | Made user fields required and improved formatting |
| src/config/env.ts | Added JWT_SECRET configuration |
| src/app.module.ts | Imported AuthModule |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const token = jwt.sign(payload, env.JWT_SECRET, { | ||
| expiresIn: '24h', | ||
| }); |
There was a problem hiding this comment.
The auth implementation uses the low-level jsonwebtoken library directly instead of leveraging NestJS's @nestjs/jwt module. This bypasses NestJS's built-in JWT integration and makes the code less maintainable. Replace the manual jwt.sign call with JwtService from @nestjs/jwt, and add JwtModule to the AuthModule imports with proper configuration.
| @IsString() | ||
| @IsNotEmpty() | ||
| @Length(8, 20) | ||
| passwordHash: string; |
There was a problem hiding this comment.
The passwordHash field in the DTO is poorly named and misleading. At this point in the request lifecycle, it contains the plain-text password from the user, not a hashed password. Rename this field to 'password' to accurately reflect its content. The hashing happens later in the service layer.
| passwordHash: string; | |
| password: string; |
| const isAdmin = req.user.userType === 'ADMIN'; | ||
| if (!isAdmin) { | ||
| throw new ForbiddenException('Only admins can access this resource'); | ||
| } | ||
| return this.usersService.findAll(); |
There was a problem hiding this comment.
The admin authorization logic is duplicated across multiple controller methods in both UsersController and TransactionsController. This code duplication makes the codebase harder to maintain and increases the risk of inconsistent authorization checks. Create a reusable RolesGuard or AdminGuard that can be applied via a decorator (e.g., @roles('ADMIN')) to eliminate this duplication.
| return this.dbClient.select() | ||
| .from(users) | ||
| .where(eq(users.id, id)); |
There was a problem hiding this comment.
The findById method returns user records including the passwordHash field. Password hashes should never be exposed in API responses. Filter out the passwordHash field from these query results using .select() with explicit field selection, or map the results to exclude the sensitive field.
| return this.dbClient.select() | |
| .from(users) | |
| .where(eq(users.id, id)); | |
| const result = await this.dbClient.select() | |
| .from(users) | |
| .where(eq(users.id, id)); | |
| return result.map(({ passwordHash, ...userWithoutPassword }) => userWithoutPassword); |
| const userId = (req as any).user.id; | ||
| return this.transactionsService.createTransaction(createTransactionDto, userId); | ||
| } | ||
|
|
||
|
|
||
| @Get() | ||
| getAllTransactions() { | ||
| getAllTransactions(@Request() req: ExpressRequest) { | ||
| const isAdmin = (req as any).user.userType === 'ADMIN'; | ||
| if (!isAdmin) { | ||
| throw new ForbiddenException('Only admins can access all transactions'); | ||
| } | ||
| return this.transactionsService.getAllTransactions(); | ||
| } | ||
|
|
||
| // GET transaction by id | ||
| @Get(':id') | ||
| getTransactionById(@Param('id', ParseIntPipe) id: number) { | ||
| return this.transactionsService.getTransactionById(id); | ||
| getTransactionById(@Param('id', ParseIntPipe) id: number, @Request() req: ExpressRequest) { | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.getTransactionById(id, userId); | ||
| } | ||
|
|
||
| // UPDATE transaction by id | ||
| @Put(':id') | ||
| updateTransaction(@Param('id',ParseIntPipe) id: number, | ||
| @Body() updateTransactionDto: UpdateTransactionDto) { | ||
| return this.transactionsService.updateTransaction(id, updateTransactionDto); | ||
| @Body() updateTransactionDto: UpdateTransactionDto, | ||
| @Request() req: ExpressRequest) { | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.updateTransaction(id, updateTransactionDto, userId); | ||
| } | ||
|
|
||
| // DELETE transaction by id | ||
| @Delete(':id') | ||
| deleteTransaction(@Param('id',ParseIntPipe) id: number){ | ||
| return this.transactionsService.deleteTransaction(id); | ||
| deleteTransaction(@Param('id',ParseIntPipe) id: number, @Request() req: ExpressRequest){ | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.deleteTransaction(id, userId); |
There was a problem hiding this comment.
Type casting req as 'any' bypasses TypeScript's type safety and is repeated throughout the controller. Define a proper type or interface that extends Express.Request with a user property, and use it consistently to eliminate these unsafe type casts.
| @Controller('transactions') | ||
| @UseGuards(JwtAuthGuard) | ||
| export class TransactionsController { | ||
| constructor(private readonly transactionsService: TransactionsService) {} | ||
|
|
||
|
|
||
| // CREATE transaction | ||
| @Post() | ||
| createTransaction(@Body() createTransactionDto: CreateTransactionDto) { | ||
| const userId = 1; | ||
| createTransaction(@Body() createTransactionDto: CreateTransactionDto, @Request() req: ExpressRequest) { | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.createTransaction(createTransactionDto, userId); | ||
| } | ||
|
|
||
|
|
||
| @Get() | ||
| getAllTransactions() { | ||
| getAllTransactions(@Request() req: ExpressRequest) { | ||
| const isAdmin = (req as any).user.userType === 'ADMIN'; | ||
| if (!isAdmin) { | ||
| throw new ForbiddenException('Only admins can access all transactions'); | ||
| } | ||
| return this.transactionsService.getAllTransactions(); | ||
| } | ||
|
|
||
| // GET transaction by id | ||
| @Get(':id') | ||
| getTransactionById(@Param('id', ParseIntPipe) id: number) { | ||
| return this.transactionsService.getTransactionById(id); | ||
| getTransactionById(@Param('id', ParseIntPipe) id: number, @Request() req: ExpressRequest) { | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.getTransactionById(id, userId); | ||
| } | ||
|
|
||
| // UPDATE transaction by id | ||
| @Put(':id') | ||
| updateTransaction(@Param('id',ParseIntPipe) id: number, | ||
| @Body() updateTransactionDto: UpdateTransactionDto) { | ||
| return this.transactionsService.updateTransaction(id, updateTransactionDto); | ||
| @Body() updateTransactionDto: UpdateTransactionDto, | ||
| @Request() req: ExpressRequest) { | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.updateTransaction(id, updateTransactionDto, userId); | ||
| } | ||
|
|
||
| // DELETE transaction by id | ||
| @Delete(':id') | ||
| deleteTransaction(@Param('id',ParseIntPipe) id: number){ | ||
| return this.transactionsService.deleteTransaction(id); | ||
| deleteTransaction(@Param('id',ParseIntPipe) id: number, @Request() req: ExpressRequest){ | ||
| const userId = (req as any).user.id; | ||
| return this.transactionsService.deleteTransaction(id, userId); | ||
| } |
There was a problem hiding this comment.
The authentication and authorization logic in the controller lacks comprehensive test coverage. The test file only contains a basic 'should be defined' test. Add unit tests for the JWT guard integration, admin authorization checks, and user ownership validation across all endpoints.
| @Injectable() | ||
| export class AuthService { | ||
| constructor(private readonly usersRepository: UsersRepository) {} | ||
|
|
||
| async login(loginDto: LoginDto) { | ||
|
|
||
|
|
||
| const user = await this.usersRepository.findByEmail(loginDto.email); | ||
|
|
||
| if (!user) { | ||
| throw new UnauthorizedException('Invalid credentials'); | ||
| } | ||
|
|
||
| //check pass | ||
| const isPasswordValid = await bcrypt.compare( | ||
| loginDto.password, | ||
| user.passwordHash | ||
| ); | ||
|
|
||
| if (!isPasswordValid) { | ||
| throw new UnauthorizedException('Invalid credentials'); | ||
| } | ||
|
|
||
| const payload = { | ||
| sub: user.id, | ||
| email: user.email, | ||
| userType: user.userType, | ||
| }; | ||
|
|
||
| if (!env.JWT_SECRET) { | ||
| throw new Error('JWT_SECRET is not configured'); | ||
| } | ||
|
|
||
| const token = jwt.sign(payload, env.JWT_SECRET, { | ||
| expiresIn: '24h', | ||
| }); | ||
|
|
||
| // 4. Return token and user info | ||
| return { | ||
| access_token: token, | ||
| user: { | ||
| id: user.id, | ||
| name: user.name, | ||
| email: user.email, | ||
| userType: user.userType, | ||
| }, | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
The authentication logic including login, password verification, and JWT token generation lacks comprehensive test coverage. The test file only contains a basic 'should be defined' test. Add unit tests for the login method covering successful authentication, invalid email, invalid password, and JWT token generation scenarios.
| async create(createUserDto: CreateUserDto) { | ||
| const hashedPassword = await bcrypt.hash(createUserDto.passwordHash, 10); | ||
| return this.usersRepository.create({ | ||
| ...createUserDto, | ||
| passwordHash: hashedPassword, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The user creation endpoint does not check if a user with the provided email already exists before attempting to create a new user. This will result in a database constraint violation error when duplicate emails are provided, which may expose internal database details. Add a check in the service to verify email uniqueness and return a clear error message (e.g., 'User with this email already exists') before attempting the database insert.
| async findAll(@Request() req: any) { | ||
| const isAdmin = req.user.userType === 'ADMIN'; | ||
| if (!isAdmin) { | ||
| throw new ForbiddenException('Only admins can access this resource'); | ||
| } | ||
| return this.usersService.findAll(); | ||
| } | ||
|
|
||
| @UseGuards(JwtAuthGuard) | ||
| @Get(':id') | ||
| async findById(@Request() req: any, @Param('id', ParseIntPipe) id: number) { | ||
| const isAdmin = req.user.userType === 'ADMIN'; | ||
| if (!isAdmin) { | ||
| throw new ForbiddenException('Only admins can access this resource'); | ||
| } | ||
| return this.usersService.findById(id); |
There was a problem hiding this comment.
Type casting req as 'any' bypasses TypeScript's type safety. Define a proper type or interface that extends Express.Request with a user property, and use it consistently throughout the controller to eliminate these unsafe type casts.
| @@ -0,0 +1,34 @@ | |||
| import { Controller, Get, Param, Body, Post, ParseIntPipe, Query, ForbiddenException, UseGuards, Request } from '@nestjs/common'; | |||
There was a problem hiding this comment.
Unused import Query.
| import { Controller, Get, Param, Body, Post, ParseIntPipe, Query, ForbiddenException, UseGuards, Request } from '@nestjs/common'; | |
| import { Controller, Get, Param, Body, Post, ParseIntPipe, ForbiddenException, UseGuards, Request } from '@nestjs/common'; |
| @Post('login') | ||
| async login(@Body() loginDto: LoginDto) { | ||
| return this.authService.login(loginDto); | ||
| } |
There was a problem hiding this comment.
@boiboi796 Just study rate limiting but you don't have to implement it rn
| } | ||
|
|
||
| const token = jwt.sign(payload, env.JWT_SECRET, { | ||
| expiresIn: '24h', |
There was a problem hiding this comment.
Use one hour for now, we can implement refresh tokens in a different PR
| createTransaction(@Body() createTransactionDto: CreateTransactionDto) { | ||
| const userId = 1; | ||
| createTransaction(@Body() createTransactionDto: CreateTransactionDto, @Request() req: ExpressRequest) { | ||
| const userId = (req as any).user.id; |
There was a problem hiding this comment.
See the comment about creating an interface
| const isAdmin = (req as any).user.userType === 'ADMIN'; | ||
| if (!isAdmin) { | ||
| throw new ForbiddenException('Only admins can access all transactions'); | ||
| } | ||
| return this.transactionsService.getAllTransactions(); |
There was a problem hiding this comment.
Create a RolesGuard
| if (!transaction) { | ||
| throw new NotFoundException('Transaction not found'); | ||
| } | ||
| if (transaction.userId !== userId) { |
There was a problem hiding this comment.
Could bypass this check by just fetching from the db by transactionId and userId.
create a repo func for that. If it returns a transaction, then the transaction muct belong to the user
| async findAll(@Query('adminId', ParseIntPipe) adminId: number) { | ||
| const isAdmin = await this.usersService.isAdmin(adminId); | ||
| async findAll(@Request() req: any) { | ||
| const isAdmin = req.user.userType === 'ADMIN'; |
| async create(createUserDto: CreateUserDto) { | ||
| return this.usersRepository.create(createUserDto); | ||
| const hashedPassword = await bcrypt.hash(createUserDto.passwordHash, 10); | ||
| return this.usersRepository.create({ |
There was a problem hiding this comment.
Create a response dto or User dto
| async create(createUserDto: CreateUserDto) { | ||
| return this.usersRepository.create(createUserDto); | ||
| const hashedPassword = await bcrypt.hash(createUserDto.passwordHash, 10); | ||
| return this.usersRepository.create({ |
There was a problem hiding this comment.
Check if the user exists first, do not depend on the db returning an error as the error might not be user friendly
| } | ||
|
|
||
| async isAdmin(id: number) { | ||
| const [user] = await this.usersRepository.findById(id); |
There was a problem hiding this comment.
findOneById should not return an array, but rather the first item in an array or null.
refactor the repo function to do this.
You can use a util function such as
export const returnFirst = (rows: T[]): T | null => rows[0] ?? null;
wrap this around the response of the repo such as
static async findOneById(whereClause: SQL | undefined, tx: Transaction): Promise<IDojo | null> {
const dojo = returnFirst(await tx.select().from(dojos).where(eq(dojos.id, id)).limit(1).execute());
return dojo || null;
}
|
|
||
|
|
||
| async getTransactionById(id: number, userId: number) { | ||
| const [transaction] = await this.transactionsRepo.findById(id); |
There was a problem hiding this comment.
findOneById should not return an array, but rather the first item in an array or null.
refactor the repo function to do this.
You can use a util function such as
export const returnFirst = (rows: T[]): T | null => rows[0] ?? null;
wrap this around the response of the repo such as
static async findOneById(whereClause: SQL | undefined, tx: Transaction): Promise<IDojo | null> {
const dojo = returnFirst(await tx.select().from(dojos).where(eq(dojos.id, id)).limit(1).execute());
return dojo || null;
}
738ec9b to
56fcef3
Compare
##What I did
I installed JWT and upgraded the security of each endpoint of the expense tracker