From 48d5e885a290e23aa57a39d8cc3ba2637e27b4cf Mon Sep 17 00:00:00 2001 From: Ibinola Date: Tue, 21 Jul 2026 10:27:59 +0100 Subject: [PATCH] feat: implement JWT token blacklist and refresh token endpoint - Add jti (JWT ID) claim to access tokens using uuid - Create TokenBlacklistService with Redis-backed storage - Blacklist access token jti on logout with TTL matching remaining token lifetime - Update JwtStrategy to check Redis blacklist before validating tokens - Issue long-lived refresh tokens on login and register - Add POST /api/auth/refresh endpoint for token rotation - Transmit refresh tokens via httpOnly cookie (not response body) - Invalidate refresh token on logout and clear cookie - Refresh token validation with revocation support - Add moduleNameMapper for src/ path alias in Jest config - Add 10 unit tests covering token blacklist and refresh flow Closes #262 --- backend/package-lock.json | 33 +--- backend/package.json | 3 + backend/src/modules/auth/auth.controller.ts | 83 +++++++- backend/src/modules/auth/auth.module.ts | 4 +- backend/src/modules/auth/auth.service.spec.ts | 135 +++++++++++++ backend/src/modules/auth/auth.service.ts | 178 +++++++++++++----- backend/src/modules/auth/jwt.strategy.ts | 11 +- .../auth/token-blacklist.service.spec.ts | 48 +++++ .../modules/auth/token-blacklist.service.ts | 65 +++++++ 9 files changed, 470 insertions(+), 90 deletions(-) create mode 100644 backend/src/modules/auth/auth.service.spec.ts create mode 100644 backend/src/modules/auth/token-blacklist.service.spec.ts create mode 100644 backend/src/modules/auth/token-blacklist.service.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 116ff0e..60e280e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1068,7 +1068,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3434,7 +3433,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -3482,7 +3480,6 @@ "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3566,7 +3563,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3727,7 +3723,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz", "integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -3843,7 +3838,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4825,7 +4819,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4997,7 +4990,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5227,7 +5219,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -5918,7 +5909,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5968,7 +5958,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6597,7 +6586,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6676,7 +6664,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -6724,7 +6711,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -6918,7 +6904,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6966,15 +6951,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -8217,7 +8200,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8278,7 +8260,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10144,7 +10125,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11024,7 +11004,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -12444,7 +12423,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -12877,7 +12855,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -13030,7 +13007,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", @@ -13302,7 +13278,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13872,7 +13847,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", "license": "MIT", - "peer": true, "workspaces": [ "./packages/*" ], @@ -14268,7 +14242,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15546,7 +15519,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15713,7 +15685,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15877,7 +15848,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16372,7 +16342,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/backend/package.json b/backend/package.json index 6cb658f..a9f9253 100644 --- a/backend/package.json +++ b/backend/package.json @@ -111,6 +111,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 a31f398..4b90871 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -1,4 +1,3 @@ -// backend/src/modules/auth/auth.controller.ts import { Controller, Post, @@ -9,7 +8,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 { JwtAuthGuard } from './jwt-auth.guard'; @@ -21,14 +22,76 @@ export class AuthController { @Post('login') @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') @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') @@ -55,9 +118,13 @@ export class AuthController { @Post('logout') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) - async logout() { - // In the app, you might want to blacklist the token - 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 f89bdc2..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], + 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 {} + } +}