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
44 changes: 43 additions & 1 deletion src/__tests__/auth/auth.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@

import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import { authRegister, authLogin, authRefreshToken, authLogout, authCreateRefreshToken } from '@/auth/auth.controller';
import { authRegister, authLogin, authRefreshToken, authLogout, authLogoutAll, authCreateRefreshToken } from '@/auth/auth.controller';
import * as validateSchema from '@/utils';
import * as formatReturn from '@/utils';
import * as handleError from '@/utils';
import * as tokenBlacklist from '@/utils/tokenBlacklist';
import * as jwt from '@/utils';
import * as helperAuth from '@/utils/helper-auth';
import * as sessionRevocation from '@/utils/sessionRevocation';
import { handlerRegister, handlerLogin } from '@/auth/auth.service';

// Mock modules
jest.mock('@/utils');
jest.mock('@/utils/tokenBlacklist');
jest.mock('@/utils/helper-auth');
jest.mock('@/utils/sessionRevocation');
jest.mock('@/auth/auth.service');
jest.mock('@/auth/auth.validate', () => ({
schemaAuthRegister: {},
Expand Down Expand Up @@ -45,6 +47,9 @@ const mockNext = jest.fn() as NextFunction;
describe('auth.controller', () => {
beforeEach(() => {
jest.clearAllMocks();
// no logout-all in effect by default
(sessionRevocation.getSessionsInvalidatedAt as jest.Mock).mockResolvedValue(null);
(sessionRevocation.isSessionRevoked as jest.Mock).mockImplementation(jest.requireActual('@/utils/sessionRevocation').isSessionRevoked);
});

describe('authRegister', () => {
Expand Down Expand Up @@ -235,6 +240,43 @@ describe('auth.controller', () => {
});
});

describe('authLogoutAll', () => {
it('invalidates all sessions for the authenticated candidate', async () => {
const req: any = mockRequest();
req.user = { _id: 'user_id' };
const res = mockResponse();

(sessionRevocation.invalidateAllSessions as jest.Mock).mockResolvedValue(true);

await authLogoutAll(req, res, mockNext);

expect(sessionRevocation.invalidateAllSessions).toHaveBeenCalledWith('user_id');
expect(formatReturn.formatReturn).toHaveBeenCalledWith(
res,
expect.objectContaining({
statusCode: StatusCodes.OK,
success: true,
message: 'Đã đăng xuất khỏi tất cả thiết bị',
}),
);
});

it('fails when there is no authenticated user on the request', async () => {
const req: any = mockRequest();
const res = mockResponse();

await authLogoutAll(req, res, mockNext);

expect(sessionRevocation.invalidateAllSessions).not.toHaveBeenCalled();
expect(formatReturn.formatReturn).toHaveBeenCalledWith(
res,
expect.objectContaining({
statusCode: StatusCodes.UNAUTHORIZED,
}),
);
});
});

describe('authCreateRefreshToken', () => {
it('should be placeholder', async () => {
const req = mockRequest();
Expand Down
22 changes: 21 additions & 1 deletion src/__tests__/auth/refreshToken.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { authRefreshToken } from '@/auth/auth.controller';
import * as tokenBlacklist from '@/utils/tokenBlacklist';
import * as jwtUtils from '@/utils/jwt';
import * as sessionRevocation from '@/utils/sessionRevocation';

jest.mock('@/utils/tokenBlacklist');
jest.mock('@/utils/jwt');
jest.mock('@/utils/sessionRevocation');

const mockedIsBlacklisted = tokenBlacklist.isBlacklisted as jest.MockedFunction<typeof tokenBlacklist.isBlacklisted>;
const mockedAddToBlacklist = tokenBlacklist.addToBlacklist as jest.MockedFunction<typeof tokenBlacklist.addToBlacklist>;
const mockedJwtVerify = jwtUtils.jwtVerify as jest.MockedFunction<typeof jwtUtils.jwtVerify>;
const mockedJwtSign = jwtUtils.jwtSign as jest.MockedFunction<typeof jwtUtils.jwtSign>;
const mockedGetSessionsInvalidatedAt = sessionRevocation.getSessionsInvalidatedAt as jest.MockedFunction<typeof sessionRevocation.getSessionsInvalidatedAt>;
const actualSessionRevocation = jest.requireActual('@/utils/sessionRevocation');

function createMocks(body?: any, headers?: Record<string, string>) {
const req: any = {
Expand All @@ -23,7 +27,11 @@ function createMocks(body?: any, headers?: Record<string, string>) {
}

describe('authRefreshToken controller', () => {
beforeEach(() => jest.resetAllMocks());
beforeEach(() => {
jest.resetAllMocks();
(sessionRevocation.isSessionRevoked as jest.Mock).mockImplementation(actualSessionRevocation.isSessionRevoked);
mockedGetSessionsInvalidatedAt.mockResolvedValue(null); // no logout-all in effect by default
});

it('returns 401 when missing refresh token', async () => {
const { req, res, next } = createMocks();
Expand Down Expand Up @@ -51,4 +59,16 @@ describe('authRefreshToken controller', () => {
expect(res.status).toHaveBeenCalledWith(200);
expect(res.status().json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
});

it('returns 403 when the refresh token predates the last logout-all (issue #74)', async () => {
mockedIsBlacklisted.mockResolvedValue(false);
mockedJwtVerify.mockReturnValue({ _id: 'user1', iat: 1000 } as any);
mockedGetSessionsInvalidatedAt.mockResolvedValue(2000);

const { req, res, next } = createMocks({}, { authorization: 'Bearer oldRefresh' });
await authRefreshToken(req, res, next);

expect(mockedAddToBlacklist).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
38 changes: 38 additions & 0 deletions src/__tests__/middlewares/verifyToken.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { verifyToken } from '@/middlewares/verifyToken.middleware';
import * as jwtUtils from '@/utils/jwt';
import * as tokenBlacklist from '@/utils/tokenBlacklist';
import * as sessionRevocation from '@/utils/sessionRevocation';
import { AuthenticationError, TokenExpiredError, InvalidTokenError, TokenRevokedError } from '@/errors';

jest.mock('@/utils/jwt');
jest.mock('@/utils/tokenBlacklist');
jest.mock('@/utils/sessionRevocation');

const mockedJwtVerify = jwtUtils.jwtVerify as jest.MockedFunction<typeof jwtUtils.jwtVerify>;
const mockedIsBlacklisted = tokenBlacklist.isBlacklisted as jest.MockedFunction<typeof tokenBlacklist.isBlacklisted>;
const mockedGetSessionsInvalidatedAt = sessionRevocation.getSessionsInvalidatedAt as jest.MockedFunction<typeof sessionRevocation.getSessionsInvalidatedAt>;
// isSessionRevoked has real, simple logic — use the actual implementation instead of a mock
const actualSessionRevocation = jest.requireActual('@/utils/sessionRevocation');

function createMocks(headers?: Record<string, string>, query?: Record<string, any>) {
const req: any = {
Expand All @@ -23,6 +28,8 @@ function createMocks(headers?: Record<string, string>, query?: Record<string, an
describe('verifyToken middleware', () => {
beforeEach(() => {
jest.resetAllMocks();
(sessionRevocation.isSessionRevoked as jest.Mock).mockImplementation(actualSessionRevocation.isSessionRevoked);
mockedGetSessionsInvalidatedAt.mockResolvedValue(null); // no logout-all in effect by default
});

it('calls next with AuthenticationError when missing token', async () => {
Expand Down Expand Up @@ -80,4 +87,35 @@ describe('verifyToken middleware', () => {
await verifyToken(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(InvalidTokenError));
});

describe('logout-all (issue #74)', () => {
it('calls next with TokenRevokedError when token was issued before the last logout-all', async () => {
mockedIsBlacklisted.mockResolvedValue(false);
mockedJwtVerify.mockReturnValue({ _id: 'abc123', iat: 1000 } as any);
mockedGetSessionsInvalidatedAt.mockResolvedValue(2000); // logout-all happened after this token was issued
const { req, res, next } = createMocks({ Authorization: 'Bearer stale' });
await verifyToken(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(TokenRevokedError));
});

it('calls next and attaches req.user when token was issued after the last logout-all', async () => {
mockedIsBlacklisted.mockResolvedValue(false);
mockedJwtVerify.mockReturnValue({ _id: 'abc123', iat: 3000 } as any);
mockedGetSessionsInvalidatedAt.mockResolvedValue(2000); // token minted after the logout-all
const { req, res, next } = createMocks({ Authorization: 'Bearer fresh' });
await verifyToken(req, res, next);
expect(next).toHaveBeenCalled();
expect(next).not.toHaveBeenCalledWith(expect.any(TokenRevokedError));
expect((req as any).user).toEqual({ _id: 'abc123' });
});

it('calls next with TokenRevokedError when a logout-all is in effect but the token has no iat', async () => {
mockedIsBlacklisted.mockResolvedValue(false);
mockedJwtVerify.mockReturnValue({ _id: 'abc123' } as any); // no iat
mockedGetSessionsInvalidatedAt.mockResolvedValue(2000);
const { req, res, next } = createMocks({ Authorization: 'Bearer no-iat' });
await verifyToken(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(TokenRevokedError));
});
});
});
44 changes: 43 additions & 1 deletion src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { validateSchema, formatReturn, handleError, throwBadRequestError } from
import { schemaAuthRegister, schemaAuthLogin, schemaForgotPassword, schemaResetPassword } from './auth.validate';
import { handlerRegister, handlerLogin, handlerForgotPassword, handlerResetPassword, handlerVerifyEmail } from './auth.service';
import { addToBlacklist, isBlacklisted } from '@/utils/tokenBlacklist';
import { invalidateAllSessions, getSessionsInvalidatedAt, isSessionRevoked } from '@/utils/sessionRevocation';
import { jwtSign, jwtVerify } from '@/utils';
import { extractTokenFromRequest } from '@/utils/helper-auth';
import { TOKEN_SECRET, TOKEN_REFRESH, TOKEN_EXP_IN, TOKEN_REFRESH_EXP_IN } from '@/config/process.config';
Expand Down Expand Up @@ -119,14 +120,25 @@ export const authRefreshToken = async (req: Request, res: Response, next: NextFu

// verify refresh token
const decoded = jwtVerify(refreshToken, TOKEN_REFRESH);
const { _id } = (decoded as { _id?: string }) || {};
const { _id, iat } = (decoded as { _id?: string; iat?: number }) || {};
if (!_id)
return formatReturn(res, {
statusCode: StatusCodes.UNAUTHORIZED,
success: false,
message: t('auth.invalidRefreshPayload', (req as any).lang),
});

// "Log out of all devices" (issue #74): a refresh token issued before
// the candidate's last logout-all must not be usable to mint new pairs.
const invalidatedAt = await getSessionsInvalidatedAt(_id);
if (isSessionRevoked(iat, invalidatedAt)) {
return formatReturn(res, {
statusCode: StatusCodes.FORBIDDEN,
success: false,
message: t('auth.refreshTokenRevoked', (req as any).lang),
});
}

// rotate: blacklist old refresh token
await addToBlacklist(refreshToken);

Expand Down Expand Up @@ -261,3 +273,33 @@ export const authLogout = async (req: Request, res: Response, next: NextFunction
handleError(err, next, (req as any).lang);
}
};

/**
* Chức năng "Log out of all devices" (issue #74): thu hồi mọi token đã
* phát cho candidate này tính đến thời điểm hiện tại — không chỉ token
* hiện tại như /logout. Yêu cầu route được gắn `verifyToken` trước, nên
* `req.user._id` luôn tồn tại khi tới đây.
*/
export const authLogoutAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const candidateId = (req as any).user?._id;

if (!candidateId) {
return formatReturn(res, {
statusCode: StatusCodes.UNAUTHORIZED,
success: false,
message: t('auth.noTokenToLogout', (req as any).lang),
});
}

await invalidateAllSessions(candidateId);

return formatReturn(res, {
statusCode: StatusCodes.OK,
success: true,
message: t('auth.logoutAllSuccess', (req as any).lang),
});
} catch (err) {
handleError(err, next, (req as any).lang);
}
};
1 change: 1 addition & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default {
tokenRefreshed: 'Token refreshed',
noTokenToLogout: 'No token provided to logout',
logoutSuccess: 'Logged out successfully',
logoutAllSuccess: 'Logged out of all devices successfully',
forgotPasswordRequested: 'If the email exists, a password reset link has been generated',
resetTokenInvalid: 'Password reset token is invalid or has expired',
resetPasswordSuccess: 'Password reset successful',
Expand Down
1 change: 1 addition & 0 deletions src/locales/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default {
tokenRefreshed: 'Làm mới token thành công',
noTokenToLogout: 'Không có token để đăng xuất',
logoutSuccess: 'Đăng xuất thành công',
logoutAllSuccess: 'Đã đăng xuất khỏi tất cả thiết bị',
forgotPasswordRequested: 'Nếu email tồn tại, liên kết đặt lại mật khẩu đã được tạo',
resetTokenInvalid: 'Token đặt lại mật khẩu không hợp lệ hoặc đã hết hạn',
resetPasswordSuccess: 'Đặt lại mật khẩu thành công',
Expand Down
11 changes: 10 additions & 1 deletion src/middlewares/verifyToken.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StatusCodes } from 'http-status-codes';
import { TOKEN_SECRET } from '@/config/process.config';
import { jwtVerify } from '@/utils/jwt';
import { isBlacklisted } from '@/utils/tokenBlacklist';
import { getSessionsInvalidatedAt, isSessionRevoked } from '@/utils/sessionRevocation';
import { ErrorCode, TokenExpiredError, TokenRevokedError, InvalidTokenError, AuthenticationError } from '@/errors';

import { extractTokenFromRequest } from '@/utils/helper-auth';
Expand All @@ -29,12 +30,20 @@ export const verifyToken = async (req: Request, res: Response, next: NextFunctio
}

const decoded = jwtVerify(token, TOKEN_SECRET);
const { _id } = (decoded as { _id?: string }) || {};
const { _id, iat } = (decoded as { _id?: string; iat?: number }) || {};

if (!_id) {
return next(new InvalidTokenError('Invalid token payload.'));
}

// "Log out of all devices" (issue #74): reject any token issued before
// the candidate's last logout-all, even if it hasn't blacklisted-out or
// expired on its own yet.
const invalidatedAt = await getSessionsInvalidatedAt(_id);
if (isSessionRevoked(iat, invalidatedAt)) {
return next(new TokenRevokedError('Token has been revoked.'));
}

// Attach authenticated user info. Also force req.body.candidateId to the
// authenticated user's own _id, overwriting whatever the client sent —
// every candidate_profile handler (list/create/update/delete/export)
Expand Down
28 changes: 27 additions & 1 deletion src/routers/api/v1/auth.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import express from 'express';
const router = express.Router();

import { authRegister, authLogin, authLogout, authRefreshToken, authForgotPassword, authResetPassword, authVerifyEmail } from '@/auth/auth.controller';
import { authRegister, authLogin, authLogout, authLogoutAll, authRefreshToken, authForgotPassword, authResetPassword, authVerifyEmail } from '@/auth/auth.controller';
import { createRateLimiter } from '@/middlewares/rateLimit.middleware';
import { verifyToken } from '@/middlewares/verifyToken.middleware';

// Apply rate limit for auth routes (150 requests per 15 minutes)
const authLimiter = createRateLimiter({ max: 150, windowMs: 15 * 60 * 1000, keyPrefix: 'auth-rl' });
Expand Down Expand Up @@ -100,6 +101,31 @@ router.get('/login', authLogin);
*/
router.post('/logout', authLogout);

/**
* @swagger
* /api/v1/auth/logout-all:
* post:
* tags: [Auth]
* summary: Log out of all devices — revoke every token issued to this candidate up to now
* description: >
* Unlike /logout (which blacklists only the current access token), this
* invalidates every access and refresh token issued before this call,
* including the one used to make this request. All devices/sessions
* will need to log in again.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: All sessions revoked
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiResponse'
* 401:
* description: Missing, invalid, expired, or already-revoked token
*/
router.post('/logout-all', verifyToken, authLogoutAll);

/**
* @swagger
* /api/v1/auth/refresh:
Expand Down
Loading
Loading