From 03bcb66e7f8a74acb41205c6da76b92f06ef8fa3 Mon Sep 17 00:00:00 2001 From: _david Date: Wed, 2 Sep 2026 18:23:53 +0700 Subject: [PATCH] feat(auth): add logout-all endpoint to revoke all sessions (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/v1/auth/logout-all so a candidate can invalidate every access/refresh token issued to them at once, not just the current one (as /logout already does). Design differs from the issue's tokenVersion-on-Candidate-model proposal: instead of adding a Mongo field + an extra DB lookup per authenticated request, this reuses the exact Redis-with-in-memory- fallback shape already used by tokenBlacklist.ts. A per-candidate 'invalidated before' timestamp is stored in Redis/mem (sessionRevocation.ts) and compared against each JWT's standard iat claim in verifyToken.middleware.ts and authRefreshToken — one more Redis/mem lookup alongside the blacklist check that already runs on every request, no schema change, no new DB round trip. - src/utils/sessionRevocation.ts: invalidateAllSessions / getSessionsInvalidatedAt / isSessionRevoked - verifyToken.middleware.ts: reject tokens issued before the last logout-all - auth.controller.ts: authLogoutAll controller + same check in authRefreshToken - auth.route.ts: POST /logout-all (authenticated via verifyToken) - locales: logoutAllSuccess (en/vi) - tests updated/added for verifyToken middleware, authRefreshToken, authLogoutAll Closes #74 --- src/__tests__/auth/auth.controller.test.ts | 44 ++++++++- src/__tests__/auth/refreshToken.test.ts | 22 ++++- src/__tests__/middlewares/verifyToken.test.ts | 38 ++++++++ src/auth/auth.controller.ts | 44 ++++++++- src/locales/en.ts | 1 + src/locales/vi.ts | 1 + src/middlewares/verifyToken.middleware.ts | 11 ++- src/routers/api/v1/auth.route.ts | 28 +++++- src/utils/sessionRevocation.ts | 94 +++++++++++++++++++ 9 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/utils/sessionRevocation.ts diff --git a/src/__tests__/auth/auth.controller.test.ts b/src/__tests__/auth/auth.controller.test.ts index 1a09890..8270356 100644 --- a/src/__tests__/auth/auth.controller.test.ts +++ b/src/__tests__/auth/auth.controller.test.ts @@ -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: {}, @@ -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', () => { @@ -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(); diff --git a/src/__tests__/auth/refreshToken.test.ts b/src/__tests__/auth/refreshToken.test.ts index 6908a64..31b4da5 100644 --- a/src/__tests__/auth/refreshToken.test.ts +++ b/src/__tests__/auth/refreshToken.test.ts @@ -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; const mockedAddToBlacklist = tokenBlacklist.addToBlacklist as jest.MockedFunction; const mockedJwtVerify = jwtUtils.jwtVerify as jest.MockedFunction; const mockedJwtSign = jwtUtils.jwtSign as jest.MockedFunction; +const mockedGetSessionsInvalidatedAt = sessionRevocation.getSessionsInvalidatedAt as jest.MockedFunction; +const actualSessionRevocation = jest.requireActual('@/utils/sessionRevocation'); function createMocks(body?: any, headers?: Record) { const req: any = { @@ -23,7 +27,11 @@ function createMocks(body?: any, headers?: Record) { } 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(); @@ -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); + }); }); diff --git a/src/__tests__/middlewares/verifyToken.test.ts b/src/__tests__/middlewares/verifyToken.test.ts index ca1753a..77eea43 100644 --- a/src/__tests__/middlewares/verifyToken.test.ts +++ b/src/__tests__/middlewares/verifyToken.test.ts @@ -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; const mockedIsBlacklisted = tokenBlacklist.isBlacklisted as jest.MockedFunction; +const mockedGetSessionsInvalidatedAt = sessionRevocation.getSessionsInvalidatedAt as jest.MockedFunction; +// isSessionRevoked has real, simple logic — use the actual implementation instead of a mock +const actualSessionRevocation = jest.requireActual('@/utils/sessionRevocation'); function createMocks(headers?: Record, query?: Record) { const req: any = { @@ -23,6 +28,8 @@ function createMocks(headers?: Record, query?: Record { 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 () => { @@ -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)); + }); + }); }); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c310834..efb171d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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'; @@ -119,7 +120,7 @@ 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, @@ -127,6 +128,17 @@ export const authRefreshToken = async (req: Request, res: Response, next: NextFu 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); @@ -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); + } +}; diff --git a/src/locales/en.ts b/src/locales/en.ts index e3d2807..cb6b89a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 5a84eee..42629f0 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -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', diff --git a/src/middlewares/verifyToken.middleware.ts b/src/middlewares/verifyToken.middleware.ts index fd7aca7..d5fe805 100644 --- a/src/middlewares/verifyToken.middleware.ts +++ b/src/middlewares/verifyToken.middleware.ts @@ -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'; @@ -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) diff --git a/src/routers/api/v1/auth.route.ts b/src/routers/api/v1/auth.route.ts index 0d62226..b4776be 100644 --- a/src/routers/api/v1/auth.route.ts +++ b/src/routers/api/v1/auth.route.ts @@ -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' }); @@ -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: diff --git a/src/utils/sessionRevocation.ts b/src/utils/sessionRevocation.ts new file mode 100644 index 0000000..e758ffa --- /dev/null +++ b/src/utils/sessionRevocation.ts @@ -0,0 +1,94 @@ +/** + * Author: Đạt Võ - https://github.com/datvt243 + * Date: `--/--` + * Description: "Log out of all devices" (issue #74) — revoke every token + * previously issued to a candidate at once, without enumerating or + * blacklisting them individually. + * + * Design note: the issue proposal suggested a `tokenVersion` field on + * the Candidate model, checked via an extra Mongo lookup per request — + * but flagged that cost as worth avoiding if possible. This reuses the + * exact same Redis-with-in-memory-fallback shape as + * `tokenBlacklist.ts` instead: store a per-candidate + * "invalidated before" timestamp, and compare it against the JWT's + * standard `iat` claim in verifyToken.middleware.ts / authRefreshToken. + * No schema change, no new DB round trip — just one more Redis/mem + * lookup alongside the blacklist check that already runs on every + * authenticated request. + */ +import { isRedisAvailable, getRedisClient } from '@/services/redis'; +import { logger } from '@/logger'; + +// Generous fixed TTL for the revocation marker — only needs to outlive +// the longest-lived token type (refresh token) so a stale marker doesn't +// linger forever, not tied precisely to TOKEN_REFRESH_EXP_IN. +const REVOCATION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days + +// In-memory fallback (used when Redis unavailable), same shape as tokenBlacklist.ts +const memoryStore = new Map(); // candidateId -> invalidated-before (unix seconds) + +const _cleanup = setInterval(() => { + const cutoff = Date.now() / 1000 - REVOCATION_TTL_SECONDS; + for (const [candidateId, invalidatedAt] of memoryStore) { + if (invalidatedAt <= cutoff) memoryStore.delete(candidateId); + } +}, 60 * 1000); +// do not keep node process alive for tests +if (typeof (_cleanup as any).unref === 'function') (_cleanup as any).unref(); + +/** + * Marks every token issued to this candidate up to now as revoked. + * Any token (access or refresh) whose `iat` predates this call will be + * rejected — including the token used to make this very request. + */ +export const invalidateAllSessions = async (candidateId: string): Promise => { + const now = Math.floor(Date.now() / 1000); + try { + if (isRedisAvailable()) { + const redis = getRedisClient(); + if (redis) { + await redis.setEx(`sessions-invalidated:${candidateId}`, REVOCATION_TTL_SECONDS, String(now)); + return true; + } + } + + memoryStore.set(candidateId, now); + return true; + } catch (err) { + logger.error('[sessionRevocation] Error invalidating sessions', { err: (err as Error).message, stack: (err as Error).stack }); + return false; + } +}; + +/** + * Returns the unix timestamp (seconds) before which all of this + * candidate's tokens are revoked, or null if `logout-all` was never + * called (or the marker has expired). + */ +export const getSessionsInvalidatedAt = async (candidateId: string): Promise => { + if (!candidateId) return null; + + try { + if (isRedisAvailable()) { + const redis = getRedisClient(); + if (redis) { + const result = await redis.get(`sessions-invalidated:${candidateId}`); + return result !== null ? parseInt(result, 10) : null; + } + } + + return memoryStore.get(candidateId) ?? null; + } catch (err) { + logger.error('[sessionRevocation] Error reading invalidation marker', { err: (err as Error).message, stack: (err as Error).stack }); + return null; + } +}; + +/** True if a token issued at `iat` (unix seconds) has been revoked by a prior logout-all. */ +export const isSessionRevoked = (iat: number | undefined, invalidatedAt: number | null): boolean => { + if (!invalidatedAt) return false; + if (!iat) return true; // no iat to compare against a revocation marker — treat as revoked, not trusted + return iat < invalidatedAt; +}; + +export default { invalidateAllSessions, getSessionsInvalidatedAt, isSessionRevoked };