Skip to content

Work on security#8

Open
boiboi796 wants to merge 12 commits into
mainfrom
Work-on-Security
Open

Work on security#8
boiboi796 wants to merge 12 commits into
mainfrom
Work-on-Security

Conversation

@boiboi796
Copy link
Copy Markdown
Collaborator

##What I did
I installed JWT and upgraded the security of each endpoint of the expense tracker

Copilot AI review requested due to automatic review settings February 13, 2026 11:23
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/auth/auth.service.ts
Comment on lines +41 to +43
const token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: '24h',
});
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@IsString()
@IsNotEmpty()
@Length(8, 20)
passwordHash: string;
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
passwordHash: string;
password: string;

Copilot uses AI. Check for mistakes.
Comment thread src/users/users.controller.ts Outdated
Comment on lines +18 to +22
const isAdmin = req.user.userType === 'ADMIN';
if (!isAdmin) {
throw new ForbiddenException('Only admins can access this resource');
}
return this.usersService.findAll();
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +30
return this.dbClient.select()
.from(users)
.where(eq(users.id, id));
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +51
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);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 8 to 52
@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);
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/auth/auth.service.ts
Comment on lines +8 to +56
@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,
},
};
}
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +17
async create(createUserDto: CreateUserDto) {
const hashedPassword = await bcrypt.hash(createUserDto.passwordHash, 10);
return this.usersRepository.create({
...createUserDto,
passwordHash: hashedPassword,
});
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/users/users.controller.ts Outdated
Comment on lines +17 to +32
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);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/users/users.controller.ts Outdated
@@ -0,0 +1,34 @@
import { Controller, Get, Param, Body, Post, ParseIntPipe, Query, ForbiddenException, UseGuards, Request } from '@nestjs/common';
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import Query.

Suggested change
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';

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +15
@Post('login')
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@boiboi796 Just study rate limiting but you don't have to implement it rn

Comment thread src/auth/auth.service.ts Outdated
}

const token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: '24h',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the comment about creating an interface

Comment on lines 24 to 28
const isAdmin = (req as any).user.userType === 'ADMIN';
if (!isAdmin) {
throw new ForbiddenException('Only admins can access all transactions');
}
return this.transactionsService.getAllTransactions();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a RolesGuard

if (!transaction) {
throw new NotFoundException('Transaction not found');
}
if (transaction.userId !== userId) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/users/users.controller.ts Outdated
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';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RolesGuard

Comment thread src/users/users.service.ts Outdated
async create(createUserDto: CreateUserDto) {
return this.usersRepository.create(createUserDto);
const hashedPassword = await bcrypt.hash(createUserDto.passwordHash, 10);
return this.usersRepository.create({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a response dto or User dto

Comment thread src/users/users.service.ts Outdated
async create(createUserDto: CreateUserDto) {
return this.usersRepository.create(createUserDto);
const hashedPassword = await bcrypt.hash(createUserDto.passwordHash, 10);
return this.usersRepository.create({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants