From 846f5c6680a963ded6fc1119ec7664bef29720f8 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 6 Jun 2023 16:36:52 +0100 Subject: [PATCH] Upgraded JWT invalidation/session logic to separate TokenVersion model. --- backend/src/controllers/v1/authController.ts | 37 ++++++--- backend/src/controllers/v2/authController.ts | 16 ++-- .../src/controllers/v2/signupController.ts | 8 +- backend/src/controllers/v3/authController.ts | 10 +-- .../src/controllers/v3/signupController.ts | 4 +- backend/src/helpers/auth.ts | 76 +++++++++++-------- backend/src/interfaces/middleware/index.ts | 2 + backend/src/middleware/requireAuth.ts | 9 ++- backend/src/models/tokenVersion.ts | 14 +++- backend/src/routes/v1/auth.ts | 14 +++- backend/src/types/express/index.d.ts | 2 + frontend/src/hooks/api/auth/index.tsx | 4 +- frontend/src/hooks/api/auth/queries.tsx | 9 +++ frontend/src/pages/settings/personal/[id].tsx | 29 ++++++- 14 files changed, 170 insertions(+), 64 deletions(-) diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index ddcbd6051..7bf86cffc 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -13,7 +13,8 @@ import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth'; import { checkUserDevice } from '../../helpers/user'; import { ACTION_LOGIN, - ACTION_LOGOUT + ACTION_LOGOUT, + AUTH_MODE_JWT } from '../../variables'; import { BadRequestError, @@ -127,7 +128,11 @@ export const login2 = async (req: Request, res: Response) => { userAgent: req.headers['user-agent'] ?? '' }); - const tokens = await issueAuthTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { @@ -181,7 +186,10 @@ export const login2 = async (req: Request, res: Response) => { */ export const logout = async (req: Request, res: Response) => { try { - await clearTokens(req.user._id); + + if (req.authData.authMode === AUTH_MODE_JWT && req.authData.authPayload instanceof User && req.authData.tokenVersionId) { + await clearTokens(req.authData.tokenVersionId) + } // clear httpOnly cookie res.cookie('jid', '', { @@ -216,6 +224,21 @@ export const logout = async (req: Request, res: Response) => { }); }; +export const revokeAllSessions = async (req: Request, res: Response) => { + await TokenVersion.updateMany({ + user: req.user._id + }, { + $inc: { + refreshVersion: 1, + accessVersion: 1 + } + }); + + return res.status(200).send({ + message: 'Successfully revoked all sessions.' + }); +} + /** * Return user is authenticated * @param req @@ -254,12 +277,7 @@ export const getNewToken = async (req: Request, res: Response) => { if (!user?.publicKey) throw new Error('Failed to authenticate not fully set up account'); - const tokenVersion = await TokenVersion.findOne({ - _id: decodedToken.tokenVersionId, - user: user._id - }); - - console.log('tokenVersion: ', tokenVersion); + const tokenVersion = await TokenVersion.findById(decodedToken.tokenVersionId); if (!tokenVersion) throw UnauthorizedRequestError({ message: 'Failed to validate refresh token' @@ -272,6 +290,7 @@ export const getNewToken = async (req: Request, res: Response) => { const token = createToken({ payload: { userId: decodedToken.userId, + tokenVersionId: tokenVersion._id.toString(), accessVersion: tokenVersion.refreshVersion }, expiresIn: await getJwtAuthLifetime(), diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index ff29c700a..99f01d241 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -22,10 +22,6 @@ import { getHttpsEnabled } from '../../config'; -// note: move this out -import path from 'path'; -import fs from 'fs'; - declare module 'jsonwebtoken' { export interface UserIDJwtPayload extends jwt.JwtPayload { userId: string; @@ -160,7 +156,11 @@ export const login2 = async (req: Request, res: Response) => { }); // issue tokens - const tokens = await issueAuthTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { @@ -301,7 +301,11 @@ export const verifyMfaToken = async (req: Request, res: Response) => { }); // issue tokens - const tokens = await issueAuthTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts index d9e9a0447..ec73da75c 100644 --- a/backend/src/controllers/v2/signupController.ts +++ b/backend/src/controllers/v2/signupController.ts @@ -116,7 +116,9 @@ export const completeAccountSignup = async (req: Request, res: Response) => { // issue tokens const tokens = await issueAuthTokens({ - userId: user._id.toString() + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' }); token = tokens.token; @@ -247,7 +249,9 @@ export const completeAccountInvite = async (req: Request, res: Response) => { // issue tokens const tokens = await issueAuthTokens({ - userId: user._id.toString() + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' }); token = tokens.token; diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 59e3ef043..cd3ee140a 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -183,12 +183,12 @@ export const login2 = async (req: Request, res: Response) => { userAgent: req.headers['user-agent'] ?? '' }); - console.log('logged in, issue tokens'); - console.log('ip: ', req.ip); - console.log('userAgent: ', req.headers['user-agent']); - // issue tokens - const tokens = await issueAuthTokens({ userId: user._id.toString() }); + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index 61b4612dc..6c09b7137 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -137,7 +137,9 @@ export const completeAccountSignup = async (req: Request, res: Response) => { // issue tokens const tokens = await issueAuthTokens({ - userId: user._id.toString() + userId: user._id, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' }); token = tokens.token; diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index be4fe4b49..ce61f3650 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -7,7 +7,8 @@ import { ServiceTokenData, ServiceAccount, APIKeyData, - TokenVersion + TokenVersion, + ITokenVersion } from '../models'; import { AccountNotFoundError, @@ -108,35 +109,32 @@ export const getAuthUserPayload = async ({ ); const user = await User.findOne({ - _id: decodedToken.userId + _id: new Types.ObjectId(decodedToken.userId) }).select('+publicKey +accessVersion'); if (!user) throw AccountNotFoundError({ message: 'Failed to find user' }); if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate user with partially set up account' }); - console.log('getAuthUserPayload'); - - const tokenVersion = await TokenVersion.findOne({ - _id: decodedToken.tokenVersionId, + const tokenVersion = await TokenVersion.findOneAndUpdate({ + _id: new Types.ObjectId(decodedToken.tokenVersionId), user: user._id + }, { + lastUsed: new Date() }); - console.log('tokenVersion: ', tokenVersion); - if (!tokenVersion) throw UnauthorizedRequestError({ message: 'Failed to validate access token' }); - if (decodedToken.accessVersion !== tokenVersion.accessVersion) { - console.log('incorrect version'); + if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError({ + message: 'Failed to validate access token' + }); - throw UnauthorizedRequestError({ - message: 'Failed to validate access token' - }); - } - - return user; + return ({ + user, + tokenVersionId: tokenVersion._id + }); } /** @@ -277,17 +275,36 @@ export const getAuthAPIKeyPayload = async ({ * @return {String} obj.token - issued JWT token * @return {String} obj.refreshToken - issued refresh token */ -export const issueAuthTokens = async ({ userId }: { userId: string }) => { - - // TODO: create tokenVersion here - // TODO: include some kind of (channel) name here +export const issueAuthTokens = async ({ + userId, + ip, + userAgent +}: { + userId: Types.ObjectId; + ip: string; + userAgent: string; +}) => { + let tokenVersion: ITokenVersion | null; - const tokenVersion = await new TokenVersion({ - user: new Types.ObjectId(userId), - name: '', // improve to channel - refreshVersion: 0, - accessVersion: 0 + // continue with (session) token version matching existing ip and user agent + tokenVersion = await TokenVersion.findOne({ + user: userId, + ip, + userAgent }); + + if (!tokenVersion) { + // case: no existing ip and user agent exists + // -> create new (session) token version for ip and user agent + tokenVersion = await new TokenVersion({ + user: userId, + refreshVersion: 0, + accessVersion: 0, + ip, + userAgent, + lastUsed: new Date() + }).save(); + } // issue tokens const token = createToken({ @@ -321,12 +338,11 @@ export const issueAuthTokens = async ({ userId }: { userId: string }) => { * @param {Object} obj * @param {String} obj.userId - id of user whose tokens are cleared. */ -export const clearTokens = async (userId: Types.ObjectId): Promise => { +export const clearTokens = async (tokenVersionId: Types.ObjectId): Promise => { // increment refreshVersion on user by 1 - - // change this - await User.findOneAndUpdate({ - _id: userId + + await TokenVersion.findOneAndUpdate({ + _id: tokenVersionId }, { $inc: { refreshVersion: 1, diff --git a/backend/src/interfaces/middleware/index.ts b/backend/src/interfaces/middleware/index.ts index 34230de05..a368a3be1 100644 --- a/backend/src/interfaces/middleware/index.ts +++ b/backend/src/interfaces/middleware/index.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { IUser, IServiceAccount, @@ -10,4 +11,5 @@ export interface AuthData { authChannel: string; authIP: string; authUserAgent: string; + tokenVersionId?: Types.ObjectId; } \ No newline at end of file diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 9ada0628b..17b7d757a 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -71,10 +71,12 @@ const requireAuth = ({ req.user = authPayload; break; default: - authPayload = await getAuthUserPayload({ + const { user, tokenVersionId } = await getAuthUserPayload({ authTokenValue }); - req.user = authPayload; + authPayload = user; + req.user = user; + req.tokenVersionId = tokenVersionId; break; } @@ -89,7 +91,8 @@ const requireAuth = ({ authPayload, // User, ServiceAccount, ServiceTokenData authChannel: getChannelFromUserAgent(req.headers['user-agent']), authIP: req.ip, - authUserAgent: req.headers['user-agent'] ?? 'other' + authUserAgent: req.headers['user-agent'] ?? 'other', + tokenVersionId: req.tokenVersionId } return next(); diff --git a/backend/src/models/tokenVersion.ts b/backend/src/models/tokenVersion.ts index c591809db..890c0a272 100644 --- a/backend/src/models/tokenVersion.ts +++ b/backend/src/models/tokenVersion.ts @@ -2,9 +2,11 @@ import { Schema, model, Types, Document } from 'mongoose'; export interface ITokenVersion extends Document { user: Types.ObjectId; - name: string; + ip: string; + userAgent: string; refreshVersion: number; accessVersion: number; + lastUsed: Date; } const tokenVersionSchema = new Schema( @@ -14,7 +16,11 @@ const tokenVersionSchema = new Schema( ref: 'User', required: true }, - name: { + ip: { + type: String, + required: true + }, + userAgent: { type: String, required: true }, @@ -25,6 +31,10 @@ const tokenVersionSchema = new Schema( accessVersion: { type: Number, required: true + }, + lastUsed: { + type: Date, + required: true } }, { diff --git a/backend/src/routes/v1/auth.ts b/backend/src/routes/v1/auth.ts index 20f5ec9b8..448bf24ea 100644 --- a/backend/src/routes/v1/auth.ts +++ b/backend/src/routes/v1/auth.ts @@ -44,8 +44,6 @@ router.post( authController.checkAuth ); - - router.get( '/redirect/google', authLimiter, @@ -53,12 +51,20 @@ router.get( scope: ['profile', 'email'], session: false, }), -) +); router.get( '/callback/google', passport.authenticate('google', { failureRedirect: '/login/provider/error', session: false }), authController.handleAuthProviderCallback, -) +); + +router.delete( + '/sessions', + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + authController.revokeAllSessions +); export default router; diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index 7cd570020..cbf390649 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -1,4 +1,5 @@ import * as express from 'express'; +import { Types } from 'mongoose'; import { IUser, IServiceAccount, @@ -39,6 +40,7 @@ declare global { serviceTokenData: any; apiKeyData: any; query?: any; + tokenVersionId?: Types.ObjectId; authData: AuthData; requestData: { [key: string]: string diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index de14c3bf4..195840efb 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -1,4 +1,6 @@ export { useGetAuthToken, useSendMfaToken, - useVerifyMfaToken} from './queries' + useVerifyMfaToken, + useRevokeAllSessions +} from './queries' diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 23e0c50fc..b5f8f2406 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -49,3 +49,12 @@ export const useGetAuthToken = () => onSuccess: (data) => setAuthToken(data.token), retry: 0 }); + +export const useRevokeAllSessions = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.delete('/api/v1/auth/sessions'); + return data; + } + }); +} \ No newline at end of file diff --git a/frontend/src/pages/settings/personal/[id].tsx b/frontend/src/pages/settings/personal/[id].tsx index 2b428aeca..a1b7703f2 100644 --- a/frontend/src/pages/settings/personal/[id].tsx +++ b/frontend/src/pages/settings/personal/[id].tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import { faCheck, faPlus, faX } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faPlus, faX, faBan } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Button from '@app/components/basic/buttons/Button'; @@ -18,6 +18,9 @@ import { SecuritySection } from '@app/views/Settings/PersonalSettingsPage/Securi import AddApiKeyDialog from '../../../components/basic/dialog/AddApiKeyDialog'; import getAPIKeys from '../../api/apiKey/getAPIKeys'; import getUser from '../../api/user/getUser'; +import { + useRevokeAllSessions +} from '@app/hooks/api'; export default function PersonalSettings() { const [personalEmail, setPersonalEmail] = useState(''); @@ -34,6 +37,8 @@ export default function PersonalSettings() { const [backupKeyError, setBackupKeyError] = useState(false); const [isAddApiKeyDialogOpen, setIsAddApiKeyDialogOpen] = useState(false); const [apiKeys, setApiKeys] = useState([]); + + const revokeAllSessions = useRevokeAllSessions(); const { t, i18n } = useTranslation(); const router = useRouter(); @@ -254,6 +259,28 @@ export default function PersonalSettings() { /> +
+
+

+ Sessions +

+
+
+
+

+ Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your account out all active sessions across all browsers and CLIs. +

+