Upgraded JWT invalidation/session logic to separate TokenVersion model.

This commit is contained in:
Tuan Dang
2023-06-06 16:36:52 +01:00
parent b9dad5c3f0
commit 846f5c6680
14 changed files with 170 additions and 64 deletions
+28 -9
View File
@@ -13,7 +13,8 @@ import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth';
import { checkUserDevice } from '../../helpers/user'; import { checkUserDevice } from '../../helpers/user';
import { import {
ACTION_LOGIN, ACTION_LOGIN,
ACTION_LOGOUT ACTION_LOGOUT,
AUTH_MODE_JWT
} from '../../variables'; } from '../../variables';
import { import {
BadRequestError, BadRequestError,
@@ -127,7 +128,11 @@ export const login2 = async (req: Request, res: Response) => {
userAgent: req.headers['user-agent'] ?? '' 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 // store (refresh) token in httpOnly cookie
res.cookie('jid', tokens.refreshToken, { 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) => { export const logout = async (req: Request, res: Response) => {
try { 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 // clear httpOnly cookie
res.cookie('jid', '', { 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 * Return user is authenticated
* @param req * @param req
@@ -254,12 +277,7 @@ export const getNewToken = async (req: Request, res: Response) => {
if (!user?.publicKey) if (!user?.publicKey)
throw new Error('Failed to authenticate not fully set up account'); throw new Error('Failed to authenticate not fully set up account');
const tokenVersion = await TokenVersion.findOne({ const tokenVersion = await TokenVersion.findById(decodedToken.tokenVersionId);
_id: decodedToken.tokenVersionId,
user: user._id
});
console.log('tokenVersion: ', tokenVersion);
if (!tokenVersion) throw UnauthorizedRequestError({ if (!tokenVersion) throw UnauthorizedRequestError({
message: 'Failed to validate refresh token' message: 'Failed to validate refresh token'
@@ -272,6 +290,7 @@ export const getNewToken = async (req: Request, res: Response) => {
const token = createToken({ const token = createToken({
payload: { payload: {
userId: decodedToken.userId, userId: decodedToken.userId,
tokenVersionId: tokenVersion._id.toString(),
accessVersion: tokenVersion.refreshVersion accessVersion: tokenVersion.refreshVersion
}, },
expiresIn: await getJwtAuthLifetime(), expiresIn: await getJwtAuthLifetime(),
+10 -6
View File
@@ -22,10 +22,6 @@ import {
getHttpsEnabled getHttpsEnabled
} from '../../config'; } from '../../config';
// note: move this out
import path from 'path';
import fs from 'fs';
declare module 'jsonwebtoken' { declare module 'jsonwebtoken' {
export interface UserIDJwtPayload extends jwt.JwtPayload { export interface UserIDJwtPayload extends jwt.JwtPayload {
userId: string; userId: string;
@@ -160,7 +156,11 @@ export const login2 = async (req: Request, res: Response) => {
}); });
// issue tokens // 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 // store (refresh) token in httpOnly cookie
res.cookie('jid', tokens.refreshToken, { res.cookie('jid', tokens.refreshToken, {
@@ -301,7 +301,11 @@ export const verifyMfaToken = async (req: Request, res: Response) => {
}); });
// issue tokens // 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 // store (refresh) token in httpOnly cookie
res.cookie('jid', tokens.refreshToken, { res.cookie('jid', tokens.refreshToken, {
@@ -116,7 +116,9 @@ export const completeAccountSignup = async (req: Request, res: Response) => {
// issue tokens // issue tokens
const tokens = await issueAuthTokens({ const tokens = await issueAuthTokens({
userId: user._id.toString() userId: user._id,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? ''
}); });
token = tokens.token; token = tokens.token;
@@ -247,7 +249,9 @@ export const completeAccountInvite = async (req: Request, res: Response) => {
// issue tokens // issue tokens
const tokens = await issueAuthTokens({ const tokens = await issueAuthTokens({
userId: user._id.toString() userId: user._id,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? ''
}); });
token = tokens.token; token = tokens.token;
+5 -5
View File
@@ -183,12 +183,12 @@ export const login2 = async (req: Request, res: Response) => {
userAgent: req.headers['user-agent'] ?? '' 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 // 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 // store (refresh) token in httpOnly cookie
res.cookie('jid', tokens.refreshToken, { res.cookie('jid', tokens.refreshToken, {
@@ -137,7 +137,9 @@ export const completeAccountSignup = async (req: Request, res: Response) => {
// issue tokens // issue tokens
const tokens = await issueAuthTokens({ const tokens = await issueAuthTokens({
userId: user._id.toString() userId: user._id,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? ''
}); });
token = tokens.token; token = tokens.token;
+46 -30
View File
@@ -7,7 +7,8 @@ import {
ServiceTokenData, ServiceTokenData,
ServiceAccount, ServiceAccount,
APIKeyData, APIKeyData,
TokenVersion TokenVersion,
ITokenVersion
} from '../models'; } from '../models';
import { import {
AccountNotFoundError, AccountNotFoundError,
@@ -108,35 +109,32 @@ export const getAuthUserPayload = async ({
); );
const user = await User.findOne({ const user = await User.findOne({
_id: decodedToken.userId _id: new Types.ObjectId(decodedToken.userId)
}).select('+publicKey +accessVersion'); }).select('+publicKey +accessVersion');
if (!user) throw AccountNotFoundError({ message: 'Failed to find user' }); if (!user) throw AccountNotFoundError({ message: 'Failed to find user' });
if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate user with partially set up account' }); if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate user with partially set up account' });
console.log('getAuthUserPayload'); const tokenVersion = await TokenVersion.findOneAndUpdate({
_id: new Types.ObjectId(decodedToken.tokenVersionId),
const tokenVersion = await TokenVersion.findOne({
_id: decodedToken.tokenVersionId,
user: user._id user: user._id
}, {
lastUsed: new Date()
}); });
console.log('tokenVersion: ', tokenVersion);
if (!tokenVersion) throw UnauthorizedRequestError({ if (!tokenVersion) throw UnauthorizedRequestError({
message: 'Failed to validate access token' message: 'Failed to validate access token'
}); });
if (decodedToken.accessVersion !== tokenVersion.accessVersion) { if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError({
console.log('incorrect version'); message: 'Failed to validate access token'
});
throw UnauthorizedRequestError({ return ({
message: 'Failed to validate access token' user,
}); tokenVersionId: tokenVersion._id
} });
return user;
} }
/** /**
@@ -277,17 +275,36 @@ export const getAuthAPIKeyPayload = async ({
* @return {String} obj.token - issued JWT token * @return {String} obj.token - issued JWT token
* @return {String} obj.refreshToken - issued refresh token * @return {String} obj.refreshToken - issued refresh token
*/ */
export const issueAuthTokens = async ({ userId }: { userId: string }) => { export const issueAuthTokens = async ({
userId,
// TODO: create tokenVersion here ip,
// TODO: include some kind of (channel) name here userAgent
}: {
userId: Types.ObjectId;
ip: string;
userAgent: string;
}) => {
let tokenVersion: ITokenVersion | null;
const tokenVersion = await new TokenVersion({ // continue with (session) token version matching existing ip and user agent
user: new Types.ObjectId(userId), tokenVersion = await TokenVersion.findOne({
name: '', // improve to channel user: userId,
refreshVersion: 0, ip,
accessVersion: 0 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 // issue tokens
const token = createToken({ const token = createToken({
@@ -321,12 +338,11 @@ export const issueAuthTokens = async ({ userId }: { userId: string }) => {
* @param {Object} obj * @param {Object} obj
* @param {String} obj.userId - id of user whose tokens are cleared. * @param {String} obj.userId - id of user whose tokens are cleared.
*/ */
export const clearTokens = async (userId: Types.ObjectId): Promise<void> => { export const clearTokens = async (tokenVersionId: Types.ObjectId): Promise<void> => {
// increment refreshVersion on user by 1 // increment refreshVersion on user by 1
// change this await TokenVersion.findOneAndUpdate({
await User.findOneAndUpdate({ _id: tokenVersionId
_id: userId
}, { }, {
$inc: { $inc: {
refreshVersion: 1, refreshVersion: 1,
@@ -1,3 +1,4 @@
import { Types } from 'mongoose';
import { import {
IUser, IUser,
IServiceAccount, IServiceAccount,
@@ -10,4 +11,5 @@ export interface AuthData {
authChannel: string; authChannel: string;
authIP: string; authIP: string;
authUserAgent: string; authUserAgent: string;
tokenVersionId?: Types.ObjectId;
} }
+6 -3
View File
@@ -71,10 +71,12 @@ const requireAuth = ({
req.user = authPayload; req.user = authPayload;
break; break;
default: default:
authPayload = await getAuthUserPayload({ const { user, tokenVersionId } = await getAuthUserPayload({
authTokenValue authTokenValue
}); });
req.user = authPayload; authPayload = user;
req.user = user;
req.tokenVersionId = tokenVersionId;
break; break;
} }
@@ -89,7 +91,8 @@ const requireAuth = ({
authPayload, // User, ServiceAccount, ServiceTokenData authPayload, // User, ServiceAccount, ServiceTokenData
authChannel: getChannelFromUserAgent(req.headers['user-agent']), authChannel: getChannelFromUserAgent(req.headers['user-agent']),
authIP: req.ip, authIP: req.ip,
authUserAgent: req.headers['user-agent'] ?? 'other' authUserAgent: req.headers['user-agent'] ?? 'other',
tokenVersionId: req.tokenVersionId
} }
return next(); return next();
+12 -2
View File
@@ -2,9 +2,11 @@ import { Schema, model, Types, Document } from 'mongoose';
export interface ITokenVersion extends Document { export interface ITokenVersion extends Document {
user: Types.ObjectId; user: Types.ObjectId;
name: string; ip: string;
userAgent: string;
refreshVersion: number; refreshVersion: number;
accessVersion: number; accessVersion: number;
lastUsed: Date;
} }
const tokenVersionSchema = new Schema<ITokenVersion>( const tokenVersionSchema = new Schema<ITokenVersion>(
@@ -14,7 +16,11 @@ const tokenVersionSchema = new Schema<ITokenVersion>(
ref: 'User', ref: 'User',
required: true required: true
}, },
name: { ip: {
type: String,
required: true
},
userAgent: {
type: String, type: String,
required: true required: true
}, },
@@ -25,6 +31,10 @@ const tokenVersionSchema = new Schema<ITokenVersion>(
accessVersion: { accessVersion: {
type: Number, type: Number,
required: true required: true
},
lastUsed: {
type: Date,
required: true
} }
}, },
{ {
+10 -4
View File
@@ -44,8 +44,6 @@ router.post(
authController.checkAuth authController.checkAuth
); );
router.get( router.get(
'/redirect/google', '/redirect/google',
authLimiter, authLimiter,
@@ -53,12 +51,20 @@ router.get(
scope: ['profile', 'email'], scope: ['profile', 'email'],
session: false, session: false,
}), }),
) );
router.get( router.get(
'/callback/google', '/callback/google',
passport.authenticate('google', { failureRedirect: '/login/provider/error', session: false }), passport.authenticate('google', { failureRedirect: '/login/provider/error', session: false }),
authController.handleAuthProviderCallback, authController.handleAuthProviderCallback,
) );
router.delete(
'/sessions',
requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT]
}),
authController.revokeAllSessions
);
export default router; export default router;
+2
View File
@@ -1,4 +1,5 @@
import * as express from 'express'; import * as express from 'express';
import { Types } from 'mongoose';
import { import {
IUser, IUser,
IServiceAccount, IServiceAccount,
@@ -39,6 +40,7 @@ declare global {
serviceTokenData: any; serviceTokenData: any;
apiKeyData: any; apiKeyData: any;
query?: any; query?: any;
tokenVersionId?: Types.ObjectId;
authData: AuthData; authData: AuthData;
requestData: { requestData: {
[key: string]: string [key: string]: string
+3 -1
View File
@@ -1,4 +1,6 @@
export { export {
useGetAuthToken, useGetAuthToken,
useSendMfaToken, useSendMfaToken,
useVerifyMfaToken} from './queries' useVerifyMfaToken,
useRevokeAllSessions
} from './queries'
+9
View File
@@ -49,3 +49,12 @@ export const useGetAuthToken = () =>
onSuccess: (data) => setAuthToken(data.token), onSuccess: (data) => setAuthToken(data.token),
retry: 0 retry: 0
}); });
export const useRevokeAllSessions = () => {
return useMutation({
mutationFn: async () => {
const { data } = await apiRequest.delete('/api/v1/auth/sessions');
return data;
}
});
}
+28 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Button from '@app/components/basic/buttons/Button'; 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 AddApiKeyDialog from '../../../components/basic/dialog/AddApiKeyDialog';
import getAPIKeys from '../../api/apiKey/getAPIKeys'; import getAPIKeys from '../../api/apiKey/getAPIKeys';
import getUser from '../../api/user/getUser'; import getUser from '../../api/user/getUser';
import {
useRevokeAllSessions
} from '@app/hooks/api';
export default function PersonalSettings() { export default function PersonalSettings() {
const [personalEmail, setPersonalEmail] = useState(''); const [personalEmail, setPersonalEmail] = useState('');
@@ -34,6 +37,8 @@ export default function PersonalSettings() {
const [backupKeyError, setBackupKeyError] = useState(false); const [backupKeyError, setBackupKeyError] = useState(false);
const [isAddApiKeyDialogOpen, setIsAddApiKeyDialogOpen] = useState(false); const [isAddApiKeyDialogOpen, setIsAddApiKeyDialogOpen] = useState(false);
const [apiKeys, setApiKeys] = useState<any[]>([]); const [apiKeys, setApiKeys] = useState<any[]>([]);
const revokeAllSessions = useRevokeAllSessions();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -254,6 +259,28 @@ export default function PersonalSettings() {
/> />
</div> </div>
</div> </div>
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-2">
<div className="my-4 flex w-full flex-row justify-between">
<p className="text-xl font-semibold w-full">
Sessions
</p>
<div className="w-40">
<Button
text="Revoke all"
onButtonPressed={async () => {
await revokeAllSessions.mutateAsync();
router.push('/login');
}}
color="mineshaft"
icon={faBan}
size="md"
/>
</div>
</div>
<p className="mb-5 text-sm text-mineshaft-300">
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.
</p>
</div>
<div className="mt-2 mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-5 pb-6"> <div className="mt-2 mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-5 pb-6">
<div className="flex w-full max-w-5xl flex-row items-center justify-between"> <div className="flex w-full max-w-5xl flex-row items-center justify-between">