diff --git a/backend/package.json b/backend/package.json index 1dc8247..319539b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -112,6 +112,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 0b6fffd..3114ba5 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -7,7 +7,9 @@ import { HttpCode, HttpStatus, Patch, + Res, } from '@nestjs/common'; +import { Response } from 'express'; import { AuthService } from './auth.service'; import { LoginDto, RegisterAdminDto, ChangePasswordDto } from './auth.dto'; import { UpdateRegisterAdminDto } from './update-auth.dto'; @@ -20,15 +22,77 @@ export class AuthController { @Post('login') @Public() @HttpCode(HttpStatus.OK) - async login(@Body() loginDto: LoginDto) { - return await this.authService.login(loginDto); + async login( + @Body() loginDto: LoginDto, + @Res({ passthrough: true }) res: Response, + ) { + const result = await this.authService.login(loginDto); + + res.cookie('refreshToken', result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + path: '/', + }); + + return { + accessToken: result.accessToken, + user: result.user, + }; } @Post('register') @Public() @HttpCode(HttpStatus.CREATED) - async register(@Body() registerAdminDto: RegisterAdminDto) { - return await this.authService.register(registerAdminDto); + async register( + @Body() registerAdminDto: RegisterAdminDto, + @Res({ passthrough: true }) res: Response, + ) { + const result = await this.authService.register(registerAdminDto); + + res.cookie('refreshToken', result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + path: '/', + }); + + return { + accessToken: result.accessToken, + user: result.user, + }; + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + async refresh( + @Body('refreshToken') refreshToken: string, + @Res({ passthrough: true }) res: Response, + ) { + if (!refreshToken) { + const cookies = res.req.cookies; + refreshToken = cookies?.refreshToken; + } + + if (!refreshToken) { + return res.status(401).json({ message: 'Refresh token not provided' }); + } + + const result = await this.authService.refreshTokens(refreshToken); + + res.cookie('refreshToken', result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + path: '/', + }); + + return { + accessToken: result.accessToken, + }; } @Post('change-password') @@ -52,8 +116,13 @@ export class AuthController { @Post('logout') @HttpCode(HttpStatus.OK) - async logout() { - return { message: 'Logged out successfully' }; + async logout(@Request() req, @Res({ passthrough: true }) res: Response) { + const authHeader = req.headers.authorization; + const accessToken = authHeader?.replace('Bearer ', ''); + + res.clearCookie('refreshToken', { path: '/' }); + + return await this.authService.logout(req.user, accessToken); } @Patch('profile') diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index ebec26a..5304f4b 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -1,4 +1,3 @@ -// backend/src/modules/auth/auth.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; @@ -9,6 +8,7 @@ import { AdminUser } from './admin-user.entity'; import { JwtStrategy } from './jwt.strategy'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { Restaurant } from '../restaurant/restaurant.entity'; +import { TokenBlacklistService } from './token-blacklist.service'; @Module({ imports: [ @@ -26,7 +26,7 @@ import { Restaurant } from '../restaurant/restaurant.entity'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [AuthService, JwtModule], + providers: [AuthService, JwtStrategy, TokenBlacklistService], + exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..a31c011 --- /dev/null +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,135 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { TokenBlacklistService } from './token-blacklist.service'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('mock-uuid-123'), +})); + +describe('AuthService', () => { + let service: AuthService; + let jwtService: JwtService; + let tokenBlacklistService: any; + + const mockAdminRepository = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + preload: jest.fn(), + }; + + const mockRestaurantRepository = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }; + + beforeEach(() => { + jwtService = { + sign: jest.fn().mockReturnValue('mock-token'), + verify: jest.fn(), + decode: jest.fn(), + } as any; + + tokenBlacklistService = { + blacklistToken: jest.fn(), + isBlacklisted: jest.fn().mockResolvedValue(false), + blacklistRefreshToken: jest.fn(), + isRefreshTokenValid: jest.fn().mockResolvedValue(true), + revokeRefreshToken: jest.fn(), + } as any; + + service = new AuthService( + mockAdminRepository as any, + jwtService, + mockRestaurantRepository as any, + tokenBlacklistService, + ); + }); + + describe('logout', () => { + it('should blacklist the access token on logout', async () => { + const mockUser = { id: '123', role: 'admin' }; + const mockToken = 'valid.jwt.token'; + + jest.spyOn(jwtService, 'decode').mockReturnValue({ + jti: 'token-jti-123', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + const result = await service.logout(mockUser, mockToken); + + expect(tokenBlacklistService.blacklistToken).toHaveBeenCalledWith( + 'token-jti-123', + expect.any(Number), + ); + expect(result).toEqual({ message: 'Logged out successfully' }); + }); + + it('should handle logout without access token', async () => { + const mockUser = { id: '123', role: 'admin' }; + + const result = await service.logout(mockUser); + + expect(tokenBlacklistService.blacklistToken).not.toHaveBeenCalled(); + expect(result).toEqual({ message: 'Logged out successfully' }); + }); + }); + + describe('refreshTokens', () => { + it('should issue new tokens with valid refresh token', async () => { + const mockAdmin = { + id: '123', + username: 'testuser', + role: 'admin', + restaurantId: 'rest-1', + isActive: true, + restaurant: { isActive: true, name: 'Test', slug: 'test' }, + }; + + jest.spyOn(jwtService, 'verify').mockReturnValue({ + sub: '123', + type: 'refresh', + jti: 'refresh-jti-123', + }); + + mockAdminRepository.findOne.mockResolvedValue(mockAdmin); + + const result = await service.refreshTokens('valid-refresh-token'); + + expect(tokenBlacklistService.isRefreshTokenValid).toHaveBeenCalledWith( + 'refresh-jti-123', + ); + expect(tokenBlacklistService.revokeRefreshToken).toHaveBeenCalledWith( + 'refresh-jti-123', + ); + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + }); + + it('should reject invalid refresh token', async () => { + jest.spyOn(jwtService, 'verify').mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await expect(service.refreshTokens('invalid-token')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should reject revoked refresh token', async () => { + jest.spyOn(jwtService, 'verify').mockReturnValue({ + sub: '123', + type: 'refresh', + jti: 'revoked-jti', + }); + + tokenBlacklistService.isRefreshTokenValid.mockResolvedValue(false); + + await expect(service.refreshTokens('revoked-token')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index eefe05d..6369c72 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,4 +1,3 @@ -// backend/src/modules/auth/auth.service.ts import { Injectable, UnauthorizedException, @@ -8,11 +7,13 @@ import { import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; import { AdminUser, AdminRole } from './admin-user.entity'; import { LoginDto, RegisterAdminDto, ChangePasswordDto } from './auth.dto'; import { UpdateRegisterAdminDto } from './update-auth.dto'; import { ErrorCatch } from 'src/errorCatch.util'; import { Restaurant } from '../restaurant/restaurant.entity'; +import { TokenBlacklistService } from './token-blacklist.service'; @Injectable() export class AuthService { @@ -22,8 +23,62 @@ export class AuthService { private readonly jwtService: JwtService, @InjectRepository(Restaurant) private readonly restaurantRepository: Repository, + private readonly tokenBlacklistService: TokenBlacklistService, ) {} + private parseExpiresIn(expiresIn: string): number { + const match = expiresIn.match(/^(\d+)([smhd])$/); + if (!match) return 86400; + const value = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case 's': + return value; + case 'm': + return value * 60; + case 'h': + return value * 3600; + case 'd': + return value * 86400; + default: + return 86400; + } + } + + private generateTokenPair(admin: AdminUser) { + const jti = uuidv4(); + const refreshJti = uuidv4(); + + const accessToken = this.jwtService.sign({ + sub: admin.id, + username: admin.username, + role: admin.role, + restaurantId: admin.restaurantId, + jti, + }); + + const refreshTokenSecret = + process.env.REFRESH_TOKEN_SECRET || 'refresh-secret'; + const refreshTokenExpiresIn = process.env.REFRESH_TOKEN_EXPIRES_IN || '7d'; + const refreshTtl = this.parseExpiresIn(refreshTokenExpiresIn); + + const refreshToken = this.jwtService.sign( + { + sub: admin.id, + type: 'refresh', + jti: refreshJti, + }, + { + secret: refreshTokenSecret, + expiresIn: refreshTtl, + }, + ); + + this.tokenBlacklistService.blacklistRefreshToken(refreshJti, refreshTtl); + + return { accessToken, refreshToken, accessJti: jti, refreshJti, refreshTtl }; + } + async login(loginDto: LoginDto) { const admin = await this.adminUserRepository.findOne({ where: { username: loginDto.username }, @@ -38,24 +93,18 @@ export class AuthService { throw new UnauthorizedException('Account is deactivated'); } - // Check if restaurant is active if (!admin.restaurant.isActive) { throw new UnauthorizedException('Restaurant account is deactivated'); } - // Update last login admin.lastLoginAt = new Date(); await this.adminUserRepository.save(admin); - const payload = { - sub: admin.id, - username: admin.username, - role: admin.role, - restaurantId: admin.restaurantId, - }; + const { accessToken, refreshToken } = this.generateTokenPair(admin); return { - accessToken: this.jwtService.sign(payload), + accessToken, + refreshToken, user: { id: admin.id, username: admin.username, @@ -72,7 +121,6 @@ export class AuthService { } async register(registerAdminDto: RegisterAdminDto) { - // Check if username already exists const existingUsername = await this.adminUserRepository.findOne({ where: { username: registerAdminDto.username }, }); @@ -81,7 +129,6 @@ export class AuthService { throw new ConflictException('Username already exists'); } - // Check if email already exists const existingEmail = await this.adminUserRepository.findOne({ where: { email: registerAdminDto.email }, }); @@ -90,13 +137,11 @@ export class AuthService { throw new ConflictException('Email already exists'); } - // Create slug from restaurant name const slug = registerAdminDto.restaurantName .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); - // Check if restaurant slug already exists const existingRestaurant = await this.restaurantRepository.findOne({ where: { slug }, }); @@ -107,7 +152,6 @@ export class AuthService { ); } - // Create restaurant first const restaurant = this.restaurantRepository.create({ name: registerAdminDto.restaurantName, slug, @@ -118,26 +162,21 @@ export class AuthService { await this.restaurantRepository.save(restaurant); - // Create admin user with SUPER_ADMIN role and link to restaurant const admin = this.adminUserRepository.create({ username: registerAdminDto.username, email: registerAdminDto.email, - passwordHash: registerAdminDto.password, // Will be hashed by @BeforeInsert + passwordHash: registerAdminDto.password, role: AdminRole.SUPER_ADMIN, restaurantId: restaurant.id, }); await this.adminUserRepository.save(admin); - const payload = { - sub: admin.id, - username: admin.username, - role: admin.role, - restaurantId: restaurant.id, - }; + const { accessToken, refreshToken } = this.generateTokenPair(admin); return { - accessToken: this.jwtService.sign(payload), + accessToken, + refreshToken, user: { id: admin.id, username: admin.username, @@ -153,6 +192,73 @@ export class AuthService { }; } + async refreshTokens(refreshToken: string) { + const refreshTokenSecret = + process.env.REFRESH_TOKEN_SECRET || 'refresh-secret'; + + let payload: any; + try { + payload = this.jwtService.verify(refreshToken, { + secret: refreshTokenSecret, + }); + } catch { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + const isValid = + await this.tokenBlacklistService.isRefreshTokenValid(payload.jti); + if (!isValid) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + await this.tokenBlacklistService.revokeRefreshToken(payload.jti); + + const admin = await this.adminUserRepository.findOne({ + where: { id: payload.sub }, + relations: ['restaurant'], + }); + + if (!admin || !admin.isActive) { + throw new UnauthorizedException('Account is deactivated'); + } + + if (!admin.restaurant.isActive) { + throw new UnauthorizedException('Restaurant account is deactivated'); + } + + const tokens = this.generateTokenPair(admin); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + + async logout(user: any, accessToken?: string) { + if (accessToken) { + try { + const decoded = this.jwtService.decode(accessToken); + if (decoded && decoded.jti) { + const expiresIn = decoded.exp + ? decoded.exp - Math.floor(Date.now() / 1000) + : 86400; + if (expiresIn > 0) { + await this.tokenBlacklistService.blacklistToken( + decoded.jti, + expiresIn, + ); + } + } + } catch {} + } + + return { message: 'Logged out successfully' }; + } + async changePassword( userId: string, changePasswordDto: ChangePasswordDto, @@ -173,7 +279,7 @@ export class AuthService { throw new UnauthorizedException('Current password is incorrect'); } - admin.passwordHash = changePasswordDto.newPassword; // Will be hashed by @BeforeUpdate + admin.passwordHash = changePasswordDto.newPassword; await this.adminUserRepository.save(admin); return { message: 'Password changed successfully' }; @@ -188,31 +294,12 @@ export class AuthService { }); } - // async createDefaultAdmin() { - // const existingAdmin = await this.adminUserRepository.findOne({ - // where: { username: process.env.ADMIN_DEFAULT_USERNAME || 'admin' }, - // }); - - // if (!existingAdmin) { - // const admin = this.adminUserRepository.create({ - // username: process.env.ADMIN_DEFAULT_USERNAME || 'admin', - // email: process.env.ADMIN_DEFAULT_EMAIL || 'admin@restaurant.com', - // passwordHash: process.env.ADMIN_DEFAULT_PASSWORD || 'changeme123', - // role: AdminRole.ADMIN, - // }); - - // await this.adminUserRepository.save(admin); - // console.log('Default admin user created'); - // } - // } - async updateUserProfile( id: string, updateUserDto: UpdateRegisterAdminDto, restaurantId: string, ) { try { - // check if user exists const existingUser = await this.adminUserRepository.findOne({ where: { id, restaurantId }, }); @@ -221,7 +308,6 @@ export class AuthService { throw new NotFoundException('User not found'); } - // Use preload to properly merge the updates with the existing entity const userToUpdate = await this.adminUserRepository.preload({ id: id, ...updateUserDto, @@ -231,10 +317,8 @@ export class AuthService { throw new NotFoundException('User not found'); } - // save the updated user const updatedUser = await this.adminUserRepository.save(userToUpdate); - // Explicitly fetch the updated user with relations to ensure we get the correct data const finalUser = await this.adminUserRepository.findOne({ where: { id: updatedUser.id }, }); diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 8f0d5f8..b2219cb 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -1,16 +1,17 @@ -// backend/src/modules/auth/jwt.strategy.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AdminUser } from './admin-user.entity'; +import { TokenBlacklistService } from './token-blacklist.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( @InjectRepository(AdminUser) private readonly adminUserRepository: Repository, + private readonly tokenBlacklistService: TokenBlacklistService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -20,6 +21,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: any) { + if (payload.jti) { + const isBlacklisted = + await this.tokenBlacklistService.isBlacklisted(payload.jti); + if (isBlacklisted) { + throw new UnauthorizedException('Token has been revoked'); + } + } + const admin = await this.adminUserRepository.findOne({ where: { id: payload.sub }, relations: ['restaurant'], diff --git a/backend/src/modules/auth/token-blacklist.service.spec.ts b/backend/src/modules/auth/token-blacklist.service.spec.ts new file mode 100644 index 0000000..2dcda13 --- /dev/null +++ b/backend/src/modules/auth/token-blacklist.service.spec.ts @@ -0,0 +1,48 @@ +import { ExecutionContext } from '@nestjs/common'; +import { TokenBlacklistService } from './token-blacklist.service'; + +describe('TokenBlacklistService', () => { + let service: TokenBlacklistService; + + beforeEach(() => { + service = new TokenBlacklistService(); + }); + + describe('blacklistToken', () => { + it('should be callable without error when not connected', async () => { + await expect( + service.blacklistToken('test-jti', 3600), + ).resolves.toBeUndefined(); + }); + }); + + describe('isBlacklisted', () => { + it('should return false when not connected', async () => { + const result = await service.isBlacklisted('test-jti'); + expect(result).toBe(false); + }); + }); + + describe('blacklistRefreshToken', () => { + it('should be callable without error when not connected', async () => { + await expect( + service.blacklistRefreshToken('test-rt-jti', 604800), + ).resolves.toBeUndefined(); + }); + }); + + describe('isRefreshTokenValid', () => { + it('should return true when not connected (fail-open)', async () => { + const result = await service.isRefreshTokenValid('test-rt-jti'); + expect(result).toBe(true); + }); + }); + + describe('revokeRefreshToken', () => { + it('should be callable without error when not connected', async () => { + await expect( + service.revokeRefreshToken('test-rt-jti'), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/backend/src/modules/auth/token-blacklist.service.ts b/backend/src/modules/auth/token-blacklist.service.ts new file mode 100644 index 0000000..07f075e --- /dev/null +++ b/backend/src/modules/auth/token-blacklist.service.ts @@ -0,0 +1,65 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { createClient, RedisClientType } from 'redis'; + +@Injectable() +export class TokenBlacklistService implements OnModuleDestroy { + private client: RedisClientType; + private isConnected = false; + + constructor() { + this.client = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + }); + this.client.on('error', () => {}); + this.client.connect().then(() => { + this.isConnected = true; + }).catch(() => {}); + } + + async onModuleDestroy() { + if (this.isConnected) { + await this.client.disconnect(); + } + } + + async blacklistToken(jti: string, ttlSeconds: number): Promise { + if (!this.isConnected) return; + try { + await this.client.setEx(`bl:${jti}`, ttlSeconds, '1'); + } catch {} + } + + async isBlacklisted(jti: string): Promise { + if (!this.isConnected) return false; + try { + const result = await this.client.get(`bl:${jti}`); + return result === '1'; + } catch { + return false; + } + } + + async blacklistRefreshToken(jti: string, ttlSeconds: number): Promise { + if (!this.isConnected) return; + try { + await this.client.setEx(`rt:${jti}`, ttlSeconds, '1'); + } catch {} + } + + async isRefreshTokenValid(jti: string): Promise { + if (!this.isConnected) return true; + try { + const result = await this.client.get(`rt:${jti}`); + return result === '1'; + } catch { + return true; + } + } + + async revokeRefreshToken(jti: string): Promise { + if (!this.isConnected) return; + try { + await this.client.del(`rt:${jti}`); + } catch {} + } +}