From d8a85b5ff3e1788f51c27a72785c1f0385ea6cd0 Mon Sep 17 00:00:00 2001 From: theChosenDevop Date: Sat, 1 Mar 2025 09:40:53 +0100 Subject: [PATCH 1/6] feat: create refresh token --- .gitignore | 5 ++ .husky/commit-msg | 2 - .husky/pre-commit | 3 -- .vscode/settings.json | 4 ++ docker-compose.yml | 36 ++++++++++++++ package.json | 3 +- src/main.ts | 3 ++ src/modules/auth/auth.controller.ts | 28 +++++++++-- src/modules/auth/auth.service.ts | 54 +++++++++++++++++++-- src/modules/auth/tests/auth.service.spec.ts | 17 +++++-- 10 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index b5f15abc6..b099f1a80 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ .idea/**/dynamic.xml .idea/**/uiDesigner.xml +# ignore db/data +data + +docker-compose.yml + # Gradle: .idea/**/gradle.xml .idea/**/libraries diff --git a/.husky/commit-msg b/.husky/commit-msg index 5a8500090..7499901d1 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1,2 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" npx --no -- commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit index 36af21989..2312dc587 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - npx lint-staged diff --git a/.vscode/settings.json b/.vscode/settings.json index eb138e9b7..74ae88c5d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,6 +33,10 @@ "source.fixAll": "explicit" }, "[typescript]": { +<<<<<<< Updated upstream "editor.defaultFormatter": "vscode.typescript-language-features" +======= + "editor.defaultFormatter": "esbenp.prettier-vscode" +>>>>>>> Stashed changes } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..6e7a36bf1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + container_name: postgres-boiler + image: postgres:latest + ports: + - '5432:5432' + environment: + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_DATABASE} + volumes: + - ./data/db:/var/lib/postgresql/data + restart: always + + adminer: + image: adminer + container_name: adminer-boiler + ports: + - '8080:8080' + restart: always + depends_on: + - postgres + + redis: + image: redis:latest + container_name: redis-boiler + ports: + - '6379:6379' + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - redis_data:/data + restart: always + +volumes: + data: + redis_data: diff --git a/package.json b/package.json index 847f7b500..b9e172cd3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/src/main", - "dev": "PROFILE=local ./node_modules/.bin/ts-node-dev -r dotenv/config --respawn src/main", + "dev": "npx ts-node-dev -r dotenv/config --respawn src/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", @@ -52,6 +52,7 @@ "bull": "^4.16.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "csv-writer": "^1.6.0", "date-fns": "^4.1.0", "file-type-mime": "^0.4.6", diff --git a/src/main.ts b/src/main.ts index adf0c73f6..3fdedeb05 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,8 +11,11 @@ import { initializeDataSource } from '@database/data-source'; import { SeedingService } from '@database/seeding/seeding.service'; import { ResponseInterceptor } from '@shared/inteceptors/response.interceptor'; import { Request, Response } from 'express'; +import * as cookieParser from 'cookie-parser'; + async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true }); + app.use(cookieParser()); const logger = app.get(Logger); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 31dee3554..5f215ecbf 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -8,7 +8,7 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import * as SYS_MSG from '@shared/constants/SystemMessages'; -import { Body, Controller, HttpCode, Post, Req, Request, Patch } from '@nestjs/common'; +import { Body, Controller, HttpCode, Post, Req, Res, Request, Patch } from '@nestjs/common'; import { CreateUserDTO } from './dto/create-user.dto'; import { skipAuth } from '@shared/helpers/skipAuth'; import AuthenticationService from './auth.service'; @@ -33,6 +33,7 @@ import { UpdatePasswordDto } from './dto/updatePasswordDto'; import { LoginErrorResponseDto } from './dto/login-error-dto'; import { UpdateUserPasswordResponseDTO } from './dto/update-user-password.dto'; import { CustomHttpException } from '@shared/helpers/custom-http-filter'; +import { Response, Request as RequestExpress } from 'express'; @ApiTags('Authentication') @Controller('auth') @@ -76,8 +77,21 @@ export default class RegistrationController { @ApiResponse({ status: 200, description: 'Login successful', type: LoginResponseDto }) @ApiUnauthorizedResponse({ description: 'Invalid credentials', type: LoginErrorResponseDto }) @HttpCode(200) - async login(@Body() loginDto: LoginDto): Promise { - return this.authService.loginUser(loginDto); + async login( + @Body() loginDto: LoginDto, + @Req() req: RequestExpress, // Express Request + @Res({ passthrough: true }) res: Response // Express Response + ): Promise { + return this.authService.loginUser(loginDto, req, res); + } + + @Post('refresh-token') + @ApiOperation({ summary: 'Refresh Access Token' }) + @ApiResponse({ status: 200, description: 'New access token issued' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @HttpCode(200) + async refreshToken(@Req() req: RequestExpress, @Res({ passthrough: true }) res: Response) { + return this.authService.refreshToken(req, res); } @skipAuth() @@ -173,4 +187,12 @@ export default class RegistrationController { public async resetPassword(@Body() updatePasswordDto: UpdatePasswordDto) { return this.authService.updateForgotPassword(updatePasswordDto); } + + @Post('logout') + @ApiOperation({ summary: 'Logout user' }) + @ApiResponse({ status: 200, description: 'User successfully logged out' }) + @HttpCode(200) + async logout(@Res({ passthrough: true }) res: Response) { + return this.authService.logout(res); + } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 3226c220d..572568faa 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -20,6 +20,7 @@ import { TokenPayload } from 'google-auth-library'; import { UpdateProfileDto } from '@modules/profile/dto/update-profile.dto'; import { RequestSigninTokenDto } from './dto/request-signin-token.dto'; import { OtpDto } from '@modules/otp/dto/otp.dto'; +import { Response, Request } from 'express'; @Injectable() export default class AuthenticationService { @@ -157,7 +158,11 @@ export default class AuthenticationService { }; } - async loginUser(loginDto: LoginDto): Promise { + async loginUser( + loginDto: LoginDto, + req: Request, + res: Response + ): Promise { const { email, password } = loginDto; const user = await this.userService.getUserRecord({ @@ -174,11 +179,28 @@ export default class AuthenticationService { if (!isMatch) { throw new CustomHttpException(SYS_MSG.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED); } - const userOranisations = await this.organisationService.getAllUserOrganisations(user.id, 1, 10); - const access_token = this.jwtService.sign({ id: user.id, sub: user.id }); - const isSuperAdmin = userOranisations.map(instance => instance.user_role).includes('super-admin'); + const userOrganisations = await this.organisationService.getAllUserOrganisations(user.id); + const isSuperAdmin = userOrganisations.some(org => org.user_role === 'super-admin'); + + let refresh_token = this.jwtService.sign({ sub: user.id }, { expiresIn: '7d' }); + + let newTokenGenerated = false; + + const access_token = this.jwtService.sign({ sub: user.id }, { expiresIn: '15m' }); + if (newTokenGenerated) { + res.cookie('refresh_token', refresh_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/auth/refresh-token', + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + } + const responsePayload = { + message: SYS_MSG.LOGIN_SUCCESSFUL, access_token, + refresh_token, data: { user: { id: user.id, @@ -188,13 +210,35 @@ export default class AuthenticationService { avatar_url: user.profile && user.profile.profile_pic_url ? user.profile.profile_pic_url : null, is_superadmin: isSuperAdmin, }, - organisations: userOranisations, + organisations: userOrganisations, }, }; return { message: SYS_MSG.LOGIN_SUCCESSFUL, ...responsePayload }; } + async logout(res: Response): Promise<{ message: string }> { + res.clearCookie('refresh_token', { path: '/auth/refresh-token' }); + return { message: 'Logged out successfully' }; + } + + async refreshToken(req: Request, res: Response): Promise<{ access_token: string } | { message: string }> { + const refresh_token = req.cookies['refresh_token']; + + if (!refresh_token) { + throw new CustomHttpException(SYS_MSG.UNAUTHORISED_TOKEN, HttpStatus.UNAUTHORIZED); + } + + try { + const decoded = this.jwtService.verify(refresh_token); + + const access_token = this.jwtService.sign({ id: decoded.id, sub: decoded.id }, { expiresIn: '15m' }); + + return { access_token }; + } catch (error) { + throw new CustomHttpException(SYS_MSG.UNAUTHORISED_TOKEN, HttpStatus.UNAUTHORIZED); + } + } private async validateUserAndPassword(user_id: string, password: string) { const user = await this.userService.getUserRecord({ identifier: user_id, diff --git a/src/modules/auth/tests/auth.service.spec.ts b/src/modules/auth/tests/auth.service.spec.ts index bc7697e59..f383f0065 100644 --- a/src/modules/auth/tests/auth.service.spec.ts +++ b/src/modules/auth/tests/auth.service.spec.ts @@ -19,9 +19,19 @@ import { LoginDto } from '../dto/login.dto'; import UserResponseDTO from '@modules/user/dto/user-response.dto'; import { Otp } from '@modules/otp/entities/otp.entity'; import { Verify2FADto } from '../dto/verify-2fa.dto'; +import { Response, Request } from 'express'; jest.mock('speakeasy'); +const mockRequest = { + cookies: { refreshToken: 'mock-refresh-token' }, +} as unknown as Request; + +const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), +} as unknown as Response; + describe('AuthenticationService', () => { let service: AuthenticationService; let userServiceMock: jest.Mocked; @@ -232,11 +242,12 @@ describe('AuthenticationService', () => { ]); jwtServiceMock.sign.mockReturnValue('jwt_token'); - const result = await service.loginUser(loginDto); + const result = await service.loginUser(loginDto, mockRequest, mockResponse); expect(result).toEqual({ message: 'Login successful', access_token: 'jwt_token', + refresh_token: 'jwt_token', data: { user: { id: '1', @@ -263,7 +274,7 @@ describe('AuthenticationService', () => { userServiceMock.getUserRecord.mockResolvedValue(null); - await expect(service.loginUser(loginDto)).rejects.toThrow(CustomHttpException); + await expect(service.loginUser(loginDto, mockRequest, mockResponse)).rejects.toThrow(CustomHttpException); }); it('should throw an unauthorized error for invalid password', async () => { @@ -282,7 +293,7 @@ describe('AuthenticationService', () => { userServiceMock.getUserRecord.mockResolvedValue(user); jest.spyOn(bcrypt, 'compare').mockImplementation(() => Promise.resolve(false)); - await expect(service.loginUser(loginDto)).rejects.toThrow(CustomHttpException); + await expect(service.loginUser(loginDto, mockRequest, mockResponse)).rejects.toThrow(CustomHttpException); }); }); From 15ea11524b74113aec62d6e7cb2b3624274bd88d Mon Sep 17 00:00:00 2001 From: Oluwatobi Adesanya Date: Sat, 1 Mar 2025 20:20:54 +0100 Subject: [PATCH 2/6] Update auth.service.ts --- src/modules/auth/auth.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index f7bf80a40..86e2e2932 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -197,7 +197,7 @@ export default class AuthenticationService { if (!isMatch) { throw new CustomHttpException(SYS_MSG.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED); } - const userOrganisations = await this.organisationService.getAllUserOrganisations(user.id); + const userOrganisations = await this.organisationService.getAllUserOrganisations(user.id, 1, 10); const isSuperAdmin = userOrganisations.some(org => org.user_role === 'super-admin'); let refresh_token = this.jwtService.sign({ sub: user.id }, { expiresIn: '7d' }); From fb96c5f0dbd285e75a3e293d857eea1d14570ebc Mon Sep 17 00:00:00 2001 From: Oluwatobi Adesanya Date: Sat, 1 Mar 2025 20:22:07 +0100 Subject: [PATCH 3/6] Update commit-msg --- .husky/commit-msg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.husky/commit-msg b/.husky/commit-msg index 7499901d1..3ea298e50 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,2 +1,3 @@ - +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" npx --no -- commitlint --edit $1 From 547a65e1b0c9175595cd1761241a72a3f32e84db Mon Sep 17 00:00:00 2001 From: Oluwatobi Adesanya Date: Sat, 1 Mar 2025 20:23:45 +0100 Subject: [PATCH 4/6] Update pre-commit --- .husky/pre-commit | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc587..36af21989 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + npx lint-staged From f0d3b87732ab752b639aefc42eee692fa74849d7 Mon Sep 17 00:00:00 2001 From: Oluwatobi Adesanya Date: Sat, 1 Mar 2025 20:24:31 +0100 Subject: [PATCH 5/6] Update settings.json --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 74ae88c5d..7f026833c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,10 +33,7 @@ "source.fixAll": "explicit" }, "[typescript]": { -<<<<<<< Updated upstream "editor.defaultFormatter": "vscode.typescript-language-features" -======= "editor.defaultFormatter": "esbenp.prettier-vscode" ->>>>>>> Stashed changes } } From b91b39ae0b3085f4f4c82cb21f4fd45e6a0b3d08 Mon Sep 17 00:00:00 2001 From: Oluwatobi Adesanya Date: Sun, 2 Mar 2025 19:53:02 +0100 Subject: [PATCH 6/6] Update auth.controller.ts Removed the comments --- src/modules/auth/auth.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 5f215ecbf..381fcf87c 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -79,8 +79,8 @@ export default class RegistrationController { @HttpCode(200) async login( @Body() loginDto: LoginDto, - @Req() req: RequestExpress, // Express Request - @Res({ passthrough: true }) res: Response // Express Response + @Req() req: RequestExpress, + @Res({ passthrough: true }) res: Response ): Promise { return this.authService.loginUser(loginDto, req, res); }