Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@
"cache-manager-redis-yet": "^5.1.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.5",
"figlet": "^1.8.0",
"helmet": "^8.0.0",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.11.5",
Expand All @@ -68,11 +68,11 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.3.9",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.21",
"@types/figlet": "^1.7.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.12",
"@types/passport-http-bearer": "^1.0.42",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0",
Expand Down
16 changes: 5 additions & 11 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as figlet from 'figlet';
import * as dotenv from 'dotenv';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';

Expand Down Expand Up @@ -34,6 +35,9 @@ async function bootstrap() {
// ASCII Art for Application Name
console.log(figlet.textSync(appName, { horizontalLayout: 'full' }));

// Cookie parser — must be registered before guards that read cookies
app.use(cookieParser());

// Security headers — Swagger UI requires 'unsafe-inline' for scripts/styles,
// but Swagger is disabled in production so production uses a strict CSP.
// frameguard and hsts are set explicitly to meet security requirements.
Expand Down Expand Up @@ -110,16 +114,6 @@ async function bootstrap() {
},
'access-token',
)
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
name: 'Refresh Token',
description: 'Enter refresh token',
in: 'header',
},
'refresh-token',
)
.build();

const document = SwaggerModule.createDocument(app, config);
Expand All @@ -129,7 +123,7 @@ async function bootstrap() {
// Log application startup information
await app.listen(port);
Logger.log(
`🚀 Application '${appName}' is running on: http://localhost:${port}`,
`Application '${appName}' is running on: http://localhost:${port}`,
'Bootstrap',
);
if (!isProduction) {
Expand Down
95 changes: 81 additions & 14 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { Controller, Post, UseGuards, Request, Body } from '@nestjs/common';
import {
Controller,
Get,
Post,
UseGuards,
Request,
Body,
Res,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
Expand All @@ -11,7 +21,8 @@ import { LocalAuthGuard } from './local-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RefreshTokenAuthGuard } from './refresh-token-auth.guard';
import { UserDto } from '../users/dto/user.dto';
import { Request as ExpressRequest } from 'express';
import { User } from '../users/user.entity';
import { Request as ExpressRequest, Response } from 'express';
import {
ChangePasswordDto,
ForgotPasswordDto,
Expand All @@ -23,6 +34,16 @@ import {
export class AuthController {
constructor(private authService: AuthService) {}

private cookieOptions(maxAge: number) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
path: '/',
maxAge,
};
}

@ApiOperation({ summary: 'Login user' })
@ApiBody({
schema: {
Expand All @@ -36,9 +57,25 @@ export class AuthController {
@ApiResponse({ status: 200, description: 'Successfully logged in' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@UseGuards(LocalAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('login')
async login(@Request() req: ExpressRequest) {
return this.authService.login(req.user);
async login(
@Request() req: ExpressRequest,
@Res({ passthrough: true }) res: Response,
) {
const user = req.user as Omit<User, 'password'>;
const tokens = await this.authService.login(user);
res.cookie(
'access_token',
tokens.accessToken,
this.cookieOptions(15 * 60 * 1000),
);
res.cookie(
'refresh_token',
tokens.refreshToken,
this.cookieOptions(7 * 24 * 60 * 60 * 1000),
);
return { message: 'Login successful', username: user.username };
}
Comment thread
GitAddRemote marked this conversation as resolved.

@ApiOperation({ summary: 'Register new user' })
Expand All @@ -50,26 +87,53 @@ export class AuthController {
return this.authService.register(userDto);
}

@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiBearerAuth('refresh-token')
@ApiOperation({ summary: 'Get current authenticated user' })
@ApiBearerAuth('access-token')
@ApiResponse({ status: 200, description: 'Current user info' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('me')
me(@Request() req: any) {
return { id: req.user.userId, username: req.user.username };
}

@ApiOperation({ summary: 'Refresh access token using refresh token cookie' })
@ApiResponse({ status: 200, description: 'Tokens refreshed successfully' })
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
@UseGuards(RefreshTokenAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('refresh')
async refresh(@Request() req: any) {
const refreshToken = req.user.refreshToken;
return this.authService.refreshAccessToken(refreshToken);
async refresh(
@Request() req: any,
@Res({ passthrough: true }) res: Response,
) {
const tokens = await this.authService.refreshAccessToken(
req.user.refreshToken,
);
res.cookie(
'access_token',
tokens.accessToken,
this.cookieOptions(15 * 60 * 1000),
);
res.cookie(
'refresh_token',
tokens.refreshToken,
this.cookieOptions(7 * 24 * 60 * 60 * 1000),
);
return { message: 'Tokens refreshed successfully' };
}
Comment thread
GitAddRemote marked this conversation as resolved.

@ApiOperation({ summary: 'Logout user and revoke refresh token' })
@ApiBearerAuth('refresh-token')
@ApiResponse({ status: 200, description: 'Successfully logged out' })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
@UseGuards(RefreshTokenAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout(@Request() req: any) {
const refreshToken = req.user.refreshToken;
await this.authService.revokeRefreshToken(refreshToken);
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
await this.authService.revokeRefreshToken(req.user.refreshToken);
const { maxAge: _maxAge, ...clearOpts } = this.cookieOptions(0);
res.clearCookie('access_token', clearOpts);
res.clearCookie('refresh_token', clearOpts);
return { message: 'Logged out successfully' };
Comment thread
GitAddRemote marked this conversation as resolved.
}

Expand All @@ -80,6 +144,7 @@ export class AuthController {
description:
'If an account with that email exists, a password reset link has been sent',
})
@HttpCode(HttpStatus.OK)
@Post('forgot-password')
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
return this.authService.requestPasswordReset(forgotPasswordDto.email);
Expand All @@ -89,19 +154,21 @@ export class AuthController {
@ApiBody({ type: ResetPasswordDto })
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
@HttpCode(HttpStatus.OK)
@Post('reset-password')
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
const { token, newPassword } = resetPasswordDto;
return this.authService.resetPassword(token, newPassword);
}

@ApiOperation({ summary: 'Change password (requires authentication)' })
@ApiBearerAuth()
@ApiBearerAuth('access-token')
@ApiBody({ type: ChangePasswordDto })
@ApiResponse({ status: 200, description: 'Password changed successfully' })
@ApiResponse({ status: 400, description: 'Current password is incorrect' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
async changePassword(
@Request() req: any,
Expand Down
3 changes: 1 addition & 2 deletions backend/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { RefreshTokenStrategy } from './refresh-token.strategy';
import { UsersModule } from '../users/users.module';
import { RefreshToken } from './refresh-token.entity';
import { PasswordReset } from './password-reset.entity';
Expand All @@ -27,7 +26,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy, RefreshTokenStrategy],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
15 changes: 6 additions & 9 deletions backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,13 @@ export class AuthService {
}

async login(
user: any,
): Promise<{ access_token: string; refresh_token: string }> {
user: Omit<User, 'password'>,
): Promise<{ accessToken: string; refreshToken: string }> {
const payload = { username: user.username, sub: user.id };
const accessToken = this.jwtService.sign(payload);
const refreshToken = await this.generateRefreshToken(user.id);

return {
access_token: accessToken,
refresh_token: refreshToken,
};
return { accessToken, refreshToken };
}

async register(userDto: UserDto): Promise<Omit<User, 'password'>> {
Expand Down Expand Up @@ -101,7 +98,7 @@ export class AuthService {

async refreshAccessToken(
refreshToken: string,
): Promise<{ access_token: string; refresh_token: string }> {
): Promise<{ accessToken: string; refreshToken: string }> {
const storedToken = await this.refreshTokenRepository.findOne({
where: { token: this.hashToken(refreshToken) },
relations: ['user'],
Expand Down Expand Up @@ -130,8 +127,8 @@ export class AuthService {
);

return {
access_token: newAccessToken,
refresh_token: newRefreshToken,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}

Expand Down
10 changes: 8 additions & 2 deletions backend/src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: ExtractJwt.fromExtractors([
// Prefer httpOnly cookie (browser clients)
(req: Request) => req?.cookies?.access_token ?? null,
// Fallback to Authorization: Bearer header (Swagger / API clients)
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
Comment thread
GitAddRemote marked this conversation as resolved.
}

async validate(payload: any) {
async validate(payload: { sub: number; username: string }) {
return { userId: payload.sub, username: payload.username };
}
}
4 changes: 2 additions & 2 deletions backend/src/modules/auth/local.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserDto } from '../users/dto/user.dto';
import { User } from '../users/user.entity';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
Expand All @@ -18,7 +18,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
async validate(
username: string,
password: string,
): Promise<Omit<UserDto, 'password'>> {
): Promise<Omit<User, 'password'>> {
this.logger.debug(`Validating user: ${username}`);

const user = await this.authService.validateUser(username, password);
Expand Down
21 changes: 18 additions & 3 deletions backend/src/modules/auth/refresh-token-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';

@Injectable()
export class RefreshTokenAuthGuard extends AuthGuard('refresh-token') {}
export class RefreshTokenAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const refreshToken = request.cookies?.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException('No refresh token provided');
}
request.user = { refreshToken };
return true;
}
}
20 changes: 0 additions & 20 deletions backend/src/modules/auth/refresh-token.strategy.ts

This file was deleted.

Loading
Loading