mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat(core): Custom session timeout and refresh configuration (#8342)
This commit is contained in:
parent
f4f496ae85
commit
07e6705256
|
@ -667,6 +667,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse {
|
||||||
|
|
||||||
export interface JwtToken {
|
export interface JwtToken {
|
||||||
token: string;
|
token: string;
|
||||||
|
/** The amount of seconds after which the JWT will expire. **/
|
||||||
expiresIn: number;
|
expiresIn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
|
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
|
||||||
import type { JwtPayload, JwtToken } from '@/Interfaces';
|
import type { JwtPayload, JwtToken } from '@/Interfaces';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
@ -14,7 +14,9 @@ import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
export function issueJWT(user: User): JwtToken {
|
export function issueJWT(user: User): JwtToken {
|
||||||
const { id, email, password } = user;
|
const { id, email, password } = user;
|
||||||
const expiresIn = 7 * 86400000; // 7 days
|
const expiresInHours = config.getEnv('userManagement.jwtSessionDurationHours');
|
||||||
|
const expiresInSeconds = expiresInHours * Time.hours.toSeconds;
|
||||||
|
|
||||||
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
|
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
|
@ -37,12 +39,12 @@ export function issueJWT(user: User): JwtToken {
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedToken = Container.get(JwtService).sign(payload, {
|
const signedToken = Container.get(JwtService).sign(payload, {
|
||||||
expiresIn: expiresIn / 1000 /* in seconds */,
|
expiresIn: expiresInSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: signedToken,
|
token: signedToken,
|
||||||
expiresIn,
|
expiresIn: expiresInSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,7 +88,7 @@ export async function resolveJwt(token: string): Promise<User> {
|
||||||
export async function issueCookie(res: Response, user: User): Promise<void> {
|
export async function issueCookie(res: Response, user: User): Promise<void> {
|
||||||
const userData = issueJWT(user);
|
const userData = issueJWT(user);
|
||||||
res.cookie(AUTH_COOKIE_NAME, userData.token, {
|
res.cookie(AUTH_COOKIE_NAME, userData.token, {
|
||||||
maxAge: userData.expiresIn,
|
maxAge: userData.expiresIn * Time.seconds.toMilliseconds,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
});
|
});
|
||||||
|
|
|
@ -62,9 +62,18 @@ if (!inE2ETests && !inTest) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate Configuration
|
||||||
config.validate({
|
config.validate({
|
||||||
allowed: 'strict',
|
allowed: 'strict',
|
||||||
});
|
});
|
||||||
|
const userManagement = config.get('userManagement');
|
||||||
|
if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) {
|
||||||
|
console.warn(
|
||||||
|
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0 for now.',
|
||||||
|
);
|
||||||
|
|
||||||
|
config.set('userManagement.jwtRefreshTimeoutHours', 0);
|
||||||
|
}
|
||||||
|
|
||||||
setGlobalState({
|
setGlobalState({
|
||||||
defaultTimezone: config.getEnv('generic.timezone'),
|
defaultTimezone: config.getEnv('generic.timezone'),
|
||||||
|
|
|
@ -762,11 +762,17 @@ export const schema = {
|
||||||
default: '',
|
default: '',
|
||||||
env: 'N8N_USER_MANAGEMENT_JWT_SECRET',
|
env: 'N8N_USER_MANAGEMENT_JWT_SECRET',
|
||||||
},
|
},
|
||||||
jwtDuration: {
|
jwtSessionDurationHours: {
|
||||||
doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts
|
doc: 'Set a specific expiration date for the JWTs in hours.',
|
||||||
format: Number,
|
format: Number,
|
||||||
default: 168,
|
default: 168,
|
||||||
env: 'N8N_USER_MANAGEMENT_JWT_DURATION',
|
env: 'N8N_USER_MANAGEMENT_JWT_DURATION_HOURS',
|
||||||
|
},
|
||||||
|
jwtRefreshTimeoutHours: {
|
||||||
|
doc: 'How long before the JWT expires to automatically refresh it. 0 means 25% of N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. -1 means it will never refresh, which forces users to login again after the defined period in N8N_USER_MANAGEMENT_JWT_DURATION_HOURS.',
|
||||||
|
format: Number,
|
||||||
|
default: 0,
|
||||||
|
env: 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS',
|
||||||
},
|
},
|
||||||
isInstanceOwnerSetUp: {
|
isInstanceOwnerSetUp: {
|
||||||
// n8n loads this setting from DB on startup
|
// n8n loads this setting from DB on startup
|
||||||
|
|
|
@ -103,6 +103,7 @@ export const UM_FIX_INSTRUCTION =
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Units of time in milliseconds
|
* Units of time in milliseconds
|
||||||
|
* @deprecated Please use constants.Time instead.
|
||||||
*/
|
*/
|
||||||
export const TIME = {
|
export const TIME = {
|
||||||
SECOND: 1000,
|
SECOND: 1000,
|
||||||
|
@ -111,6 +112,28 @@ export const TIME = {
|
||||||
DAY: 24 * 60 * 60 * 1000,
|
DAY: 24 * 60 * 60 * 1000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert time from any unit to any other unit
|
||||||
|
*
|
||||||
|
* Please amend conversions as necessary.
|
||||||
|
* Eventually this will superseed `TIME` above
|
||||||
|
*/
|
||||||
|
export const Time = {
|
||||||
|
seconds: {
|
||||||
|
toMilliseconds: 1000,
|
||||||
|
},
|
||||||
|
minutes: {
|
||||||
|
toMilliseconds: 60 * 1000,
|
||||||
|
},
|
||||||
|
hours: {
|
||||||
|
toMilliseconds: 60 * 60 * 1000,
|
||||||
|
toSeconds: 60 * 60,
|
||||||
|
},
|
||||||
|
days: {
|
||||||
|
toSeconds: 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const MIN_PASSWORD_CHAR_LENGTH = 8;
|
export const MIN_PASSWORD_CHAR_LENGTH = 8;
|
||||||
|
|
||||||
export const MAX_PASSWORD_CHAR_LENGTH = 64;
|
export const MAX_PASSWORD_CHAR_LENGTH = 64;
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { issueCookie, resolveJwtContent } from '@/auth/jwt';
|
||||||
import { canSkipAuth } from '@/decorators/registerController';
|
import { canSkipAuth } from '@/decorators/registerController';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import { JwtService } from '@/services/jwt.service';
|
import { JwtService } from '@/services/jwt.service';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
const jwtFromRequest = (req: Request) => {
|
const jwtFromRequest = (req: Request) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
@ -41,17 +42,29 @@ const userManagementJwtAuth = (): RequestHandler => {
|
||||||
/**
|
/**
|
||||||
* middleware to refresh cookie before it expires
|
* middleware to refresh cookie before it expires
|
||||||
*/
|
*/
|
||||||
const refreshExpiringCookie: RequestHandler = async (req: AuthenticatedRequest, res, next) => {
|
export const refreshExpiringCookie = (async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
const jwtRefreshTimeoutHours = config.get('userManagement.jwtRefreshTimeoutHours');
|
||||||
|
|
||||||
|
let jwtRefreshTimeoutMilliSeconds: number;
|
||||||
|
|
||||||
|
if (jwtRefreshTimeoutHours === 0) {
|
||||||
|
const jwtSessionDurationHours = config.get('userManagement.jwtSessionDurationHours');
|
||||||
|
|
||||||
|
jwtRefreshTimeoutMilliSeconds = Math.floor(jwtSessionDurationHours * 0.25 * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
jwtRefreshTimeoutMilliSeconds = Math.floor(jwtRefreshTimeoutHours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const cookieAuth = jwtFromRequest(req);
|
const cookieAuth = jwtFromRequest(req);
|
||||||
if (cookieAuth && req.user) {
|
|
||||||
|
if (cookieAuth && req.user && jwtRefreshTimeoutHours !== -1) {
|
||||||
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
|
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
|
||||||
if (cookieContents.exp * 1000 - Date.now() < 259200000) {
|
if (cookieContents.exp * 1000 - Date.now() < jwtRefreshTimeoutMilliSeconds) {
|
||||||
// if cookie expires in < 3 days, renew it.
|
|
||||||
await issueCookie(res, req.user);
|
await issueCookie(res, req.user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
};
|
}) satisfies RequestHandler;
|
||||||
|
|
||||||
const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler;
|
const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler;
|
||||||
|
|
||||||
|
|
61
packages/cli/test/unit/auth/jwt.test.ts
Normal file
61
packages/cli/test/unit/auth/jwt.test.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
import { JwtService } from '@/services/jwt.service';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { Time } from '@/constants';
|
||||||
|
import { issueJWT } from '@/auth/jwt';
|
||||||
|
|
||||||
|
import { mockInstance } from '../../shared/mocking';
|
||||||
|
|
||||||
|
import type { User } from '@db/entities/User';
|
||||||
|
|
||||||
|
mockInstance(License);
|
||||||
|
|
||||||
|
describe('jwt.issueJWT', () => {
|
||||||
|
const jwtService = Container.get(JwtService);
|
||||||
|
|
||||||
|
describe('when not setting userManagement.jwtSessionDuration', () => {
|
||||||
|
it('should default to expire in 7 days', () => {
|
||||||
|
const defaultInSeconds = 7 * Time.days.toSeconds;
|
||||||
|
const mockUser = mock<User>({ password: 'passwordHash' });
|
||||||
|
const { token, expiresIn } = issueJWT(mockUser);
|
||||||
|
|
||||||
|
expect(expiresIn).toBe(defaultInSeconds);
|
||||||
|
const decodedToken = jwtService.verify(token);
|
||||||
|
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
|
||||||
|
fail('Expected exp and iat to be defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(decodedToken.exp - decodedToken.iat).toBe(defaultInSeconds);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setting userManagement.jwtSessionDuration', () => {
|
||||||
|
const oldDuration = config.get('userManagement.jwtSessionDurationHours');
|
||||||
|
const testDurationHours = 1;
|
||||||
|
const testDurationSeconds = testDurationHours * Time.hours.toSeconds;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockInstance(License);
|
||||||
|
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
config.set('userManagement.jwtSessionDuration', oldDuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply it to tokens', () => {
|
||||||
|
const mockUser = mock<User>({ password: 'passwordHash' });
|
||||||
|
const { token, expiresIn } = issueJWT(mockUser);
|
||||||
|
|
||||||
|
expect(expiresIn).toBe(testDurationSeconds);
|
||||||
|
const decodedToken = jwtService.verify(token);
|
||||||
|
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
|
||||||
|
fail('Expected exp and iat to be defined on decodedToken');
|
||||||
|
}
|
||||||
|
expect(decodedToken.exp - decodedToken.iat).toBe(testDurationSeconds);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
9
packages/cli/test/unit/config/index.test.ts
Normal file
9
packages/cli/test/unit/config/index.test.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
describe('userManagement.jwtRefreshTimeoutHours', () => {
|
||||||
|
it("resets jwtRefreshTimeoutHours to 0 if it's greater than or equal to jwtSessionDurationHours", async () => {
|
||||||
|
process.env.N8N_USER_MANAGEMENT_JWT_DURATION_HOURS = '1';
|
||||||
|
process.env.N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS = '1';
|
||||||
|
const { default: config } = await import('@/config');
|
||||||
|
|
||||||
|
expect(config.getEnv('userManagement.jwtRefreshTimeoutHours')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
162
packages/cli/test/unit/middleware/auth.test.ts
Normal file
162
packages/cli/test/unit/middleware/auth.test.ts
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
import { AUTH_COOKIE_NAME, Time } from '@/constants';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { issueJWT } from '@/auth/jwt';
|
||||||
|
import { refreshExpiringCookie } from '@/middlewares';
|
||||||
|
|
||||||
|
import { mockInstance } from '../../shared/mocking';
|
||||||
|
|
||||||
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
|
import type { NextFunction, Response } from 'express';
|
||||||
|
import type { User } from '@/databases/entities/User';
|
||||||
|
|
||||||
|
mockInstance(License);
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
describe('refreshExpiringCookie', () => {
|
||||||
|
const oldDuration = config.getEnv('userManagement.jwtSessionDurationHours');
|
||||||
|
const oldTimeout = config.getEnv('userManagement.jwtRefreshTimeoutHours');
|
||||||
|
let mockUser: User;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUser = mock<User>({ password: 'passwordHash' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
config.set('userManagement.jwtSessionDuration', oldDuration);
|
||||||
|
config.set('userManagement.jwtRefreshTimeoutHours', oldTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not do anything if the user is not authorized', async () => {
|
||||||
|
const req = mock<AuthenticatedRequest>();
|
||||||
|
const res = mock<Response>({ cookie: jest.fn() });
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await refreshExpiringCookie(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=-1', () => {
|
||||||
|
it('does not refresh the cookie, ever', async () => {
|
||||||
|
config.set('userManagement.jwtSessionDurationHours', 1);
|
||||||
|
config.set('userManagement.jwtRefreshTimeoutHours', -1);
|
||||||
|
const { token } = issueJWT(mockUser);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000 * 60 * 55); /* 55 minutes */
|
||||||
|
|
||||||
|
const req = mock<AuthenticatedRequest>({
|
||||||
|
cookies: { [AUTH_COOKIE_NAME]: token },
|
||||||
|
user: mockUser,
|
||||||
|
});
|
||||||
|
const res = mock<Response>({ cookie: jest.fn() });
|
||||||
|
const next = jest.fn();
|
||||||
|
await refreshExpiringCookie(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=0', () => {
|
||||||
|
let token: string;
|
||||||
|
let req: AuthenticatedRequest;
|
||||||
|
let res: Response;
|
||||||
|
let next: NextFunction;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// ARRANGE
|
||||||
|
config.set('userManagement.jwtSessionDurationHours', 1);
|
||||||
|
config.set('userManagement.jwtRefreshTimeoutHours', 0);
|
||||||
|
token = issueJWT(mockUser).token;
|
||||||
|
|
||||||
|
req = mock<AuthenticatedRequest>({
|
||||||
|
cookies: { [AUTH_COOKIE_NAME]: token },
|
||||||
|
user: mockUser,
|
||||||
|
});
|
||||||
|
res = mock<Response>({ cookie: jest.fn() });
|
||||||
|
next = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not refresh the cookie when more than 1/4th of time is left', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
jest.advanceTimersByTime(44 * Time.minutes.toMilliseconds); /* 44 minutes */
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await refreshExpiringCookie(req, res, next);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the cookie when 1/4th of time is left', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
jest.advanceTimersByTime(46 * Time.minutes.toMilliseconds); /* 46 minutes */
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await refreshExpiringCookie(req, res, next);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS=50', () => {
|
||||||
|
const jwtSessionDurationHours = 51;
|
||||||
|
let token: string;
|
||||||
|
let req: AuthenticatedRequest;
|
||||||
|
let res: Response;
|
||||||
|
let next: NextFunction;
|
||||||
|
|
||||||
|
// ARRANGE
|
||||||
|
beforeEach(() => {
|
||||||
|
config.set('userManagement.jwtSessionDurationHours', jwtSessionDurationHours);
|
||||||
|
config.set('userManagement.jwtRefreshTimeoutHours', 50);
|
||||||
|
|
||||||
|
token = issueJWT(mockUser).token;
|
||||||
|
req = mock<AuthenticatedRequest>({
|
||||||
|
cookies: { [AUTH_COOKIE_NAME]: token },
|
||||||
|
user: mockUser,
|
||||||
|
});
|
||||||
|
res = mock<Response>({ cookie: jest.fn() });
|
||||||
|
next = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not do anything if the cookie is still valid', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
// cookie has 50.5 hours to live: 51 - 0.5
|
||||||
|
jest.advanceTimersByTime(30 * Time.minutes.toMilliseconds);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await refreshExpiringCookie(req, res, next);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the cookie if it has less than 50 hours to live', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
// cookie has 49.5 hours to live: 51 - 1.5
|
||||||
|
jest.advanceTimersByTime(1.5 * Time.hours.toMilliseconds);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await refreshExpiringCookie(req, res, next);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, expect.any(String), {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: jwtSessionDurationHours * Time.hours.toMilliseconds,
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue