Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fix] Login Refresh Token to keep Users Logged In. Fixes Issue[1260] #1346

Open
wants to merge 9 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions .gitignore
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is everyone touching this file and changing this??

Copy link
Author

Choose a reason for hiding this comment

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

I was unable to push to the repository. The .husky pre-commit and commit-msg prevented git commit and was unable to effectively ignore .husky file

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml

# ignore db/data
data

docker-compose.yml

# Gradle:
.idea/**/gradle.xml
.idea/**/libraries
Expand Down
1 change: 0 additions & 1 deletion .husky/commit-msg
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you touch this??

Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx --no -- commitlint --edit $1
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
36 changes: 36 additions & 0 deletions docker-compose.yml
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you push this??

Copy link
Author

Choose a reason for hiding this comment

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

My mistake

Original file line number Diff line number Diff line change
@@ -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:
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main",
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you change this??

Copy link
Author

@theChosenDevop theChosenDevop Mar 2, 2025

Choose a reason for hiding this comment

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

"dev": "PROFILE=local was unable to run, so I had to use "npx ts-node-dev -r dotenv/config --respawn 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",
Expand Down Expand Up @@ -54,6 +54,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",
Expand Down
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NestExpressApplication>(AppModule, { bufferLogs: true });
app.use(cookieParser());

const logger = app.get(Logger);

Expand Down
28 changes: 25 additions & 3 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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')
Expand Down Expand Up @@ -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<LoginResponseDto | { status_code: number; message: string }> {
return this.authService.loginUser(loginDto);
async login(
@Body() loginDto: LoginDto,
Copy link
Contributor

Choose a reason for hiding this comment

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

Remove these comments.

Copy link
Author

Choose a reason for hiding this comment

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

done

@Req() req: RequestExpress, // Express Request
@Res({ passthrough: true }) res: Response // Express Response
): Promise<LoginResponseDto | { status_code: number; message: string }> {
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()
Expand Down Expand Up @@ -173,4 +187,12 @@ export default class RegistrationController {
public async resetPassword(@Body() updatePasswordDto: UpdatePasswordDto) {
return this.authService.updateForgotPassword(updatePasswordDto);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Logout is being handled on the FE

Copy link
Author

Choose a reason for hiding this comment

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

Okay

@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);
}
}
57 changes: 52 additions & 5 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ 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';


import { DataSource, EntityManager } from 'typeorm';
import { CreateOrganisationRecordOptions } from '@modules/organisations/dto/create-organisation-options';

Expand Down Expand Up @@ -172,7 +176,11 @@ export default class AuthenticationService {
};
}

async loginUser(loginDto: LoginDto): Promise<LoginResponseDto | { status_code: number; message: string }> {
async loginUser(
loginDto: LoginDto,
req: Request,
res: Response
): Promise<LoginResponseDto | { status_code: number; message: string }> {
const { email, password } = loginDto;

const user = await this.userService.getUserRecord({
Expand All @@ -189,11 +197,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, 1, 10);
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,
Expand All @@ -203,13 +228,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,
Expand Down
20 changes: 17 additions & 3 deletions src/modules/auth/tests/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,22 @@ 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';

import { DataSource, EntityManager } from 'typeorm';

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<UserService>;
Expand Down Expand Up @@ -241,11 +254,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',
Expand All @@ -272,7 +286,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 () => {
Expand All @@ -291,7 +305,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);
});
});

Expand Down