refactor(core): Remove all legacy auth middleware code (no-changelog) (#8755)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-02-28 13:12:28 +01:00 committed by GitHub
parent 2e84684f04
commit 56c8791aff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 679 additions and 864 deletions

View file

@ -72,10 +72,8 @@
"@types/json-diff": "^1.0.0",
"@types/jsonwebtoken": "^9.0.1",
"@types/lodash": "^4.14.195",
"@types/passport-jwt": "^3.0.6",
"@types/psl": "^1.1.0",
"@types/replacestream": "^4.0.1",
"@types/send": "^0.17.1",
"@types/shelljs": "^0.8.11",
"@types/sshpk": "^1.17.1",
"@types/superagent": "4.1.13",
@ -153,9 +151,6 @@
"otpauth": "9.1.1",
"p-cancelable": "2.1.1",
"p-lazy": "3.1.0",
"passport": "0.6.0",
"passport-cookie": "1.0.9",
"passport-jwt": "4.0.1",
"pg": "8.11.3",
"picocolors": "1.0.0",
"pkce-challenge": "3.0.0",

View file

@ -613,18 +613,6 @@ export interface ILicensePostResponse extends ILicenseReadResponse {
managementToken: string;
}
export interface JwtToken {
token: string;
/** The amount of seconds after which the JWT will expire. **/
expiresIn: number;
}
export interface JwtPayload {
id: string;
email: string | null;
password: string | null;
}
export interface PublicUser {
id: string;
email?: string;

View file

@ -62,7 +62,6 @@ import { EventBusController } from '@/eventbus/eventBus.controller';
import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee';
import { LicenseController } from '@/license/license.controller';
import { setupPushServer, setupPushHandler } from '@/push';
import { setupAuthMiddlewares } from './middlewares';
import { isLdapEnabled } from './Ldap/helpers';
import { AbstractServer } from './AbstractServer';
import { PostHogClient } from './posthog';
@ -129,9 +128,8 @@ export class Server extends AbstractServer {
Container.get(CollaborationService);
}
private async registerControllers(ignoredEndpoints: Readonly<string[]>) {
private async registerControllers() {
const { app } = this;
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
const controllers: Array<Class<object>> = [
EventBusController,
@ -278,7 +276,7 @@ export class Server extends AbstractServer {
await handleMfaDisable();
await this.registerControllers(ignoredEndpoints);
await this.registerControllers();
// ----------------------------------------
// SAML

View file

@ -0,0 +1,224 @@
import { Service } from 'typedi';
import type { Response } from 'express';
import { createHash } from 'crypto';
import { JsonWebTokenError, TokenExpiredError, type JwtPayload } from 'jsonwebtoken';
import config from '@/config';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import type { AuthRole } from '@/decorators/types';
import { AuthError } from '@/errors/response-errors/auth.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { License } from '@/License';
import { Logger } from '@/Logger';
import type { AuthenticatedRequest, AsyncRequestHandler } from '@/requests';
import { JwtService } from '@/services/jwt.service';
import { UrlService } from '@/services/url.service';
interface AuthJwtPayload {
/** User Id */
id: string;
/** User's email */
email: string | null;
/** SHA-256 hash of bcrypt hash of the user's password */
password: string | null;
}
interface IssuedJWT extends AuthJwtPayload {
exp: number;
}
@Service()
export class AuthService {
private middlewareCache = new Map<string, AsyncRequestHandler>();
constructor(
private readonly logger: Logger,
private readonly license: License,
private readonly jwtService: JwtService,
private readonly urlService: UrlService,
private readonly userRepository: UserRepository,
) {}
createAuthMiddleware(authRole: AuthRole): AsyncRequestHandler {
const { middlewareCache: cache } = this;
let authMiddleware = cache.get(authRole);
if (authMiddleware) return authMiddleware;
authMiddleware = async (req: AuthenticatedRequest, res, next) => {
if (authRole === 'none') {
next();
return;
}
const token = req.cookies[AUTH_COOKIE_NAME];
if (token) {
try {
req.user = await this.resolveJwt(token, res);
} catch (error) {
if (error instanceof JsonWebTokenError || error instanceof AuthError) {
this.clearCookie(res);
} else {
throw error;
}
}
}
if (!req.user) {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
return;
}
if (authRole === 'any' || authRole === req.user.role) {
next();
} else {
res.status(403).json({ status: 'error', message: 'Forbidden' });
}
};
cache.set(authRole, authMiddleware);
return authMiddleware;
}
clearCookie(res: Response) {
res.clearCookie(AUTH_COOKIE_NAME);
}
issueCookie(res: Response, user: User) {
// If the instance has exceeded its user quota, prevent non-owners from logging in
const isWithinUsersLimit = this.license.isWithinUsersLimit();
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
!user.isOwner &&
!isWithinUsersLimit
) {
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
const token = this.issueJWT(user);
res.cookie(AUTH_COOKIE_NAME, token, {
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
httpOnly: true,
sameSite: 'lax',
});
}
issueJWT(user: User) {
const { id, email, password } = user;
const payload: AuthJwtPayload = {
id,
email,
password: password ? this.createPasswordSha(user) : null,
};
return this.jwtService.sign(payload, {
expiresIn: this.jwtExpiration,
});
}
async resolveJwt(token: string, res: Response): Promise<User> {
const jwtPayload: IssuedJWT = this.jwtService.verify(token, {
algorithms: ['HS256'],
});
// TODO: Use an in-memory ttl-cache to cache the User object for upto a minute
const user = await this.userRepository.findOne({
where: { id: jwtPayload.id },
});
// TODO: include these checks in the cache, to avoid computed this over and over again
const passwordHash = user?.password ? this.createPasswordSha(user) : null;
if (
// If not user is found
!user ||
// or, If the user has been deactivated (i.e. LDAP users)
user.disabled ||
// or, If the password has been updated
jwtPayload.password !== passwordHash ||
// or, If the email has been updated
user.email !== jwtPayload.email
) {
throw new AuthError('Unauthorized');
}
if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) {
this.logger.debug('JWT about to expire. Will be refreshed');
this.issueCookie(res, user);
}
return user;
}
generatePasswordResetToken(user: User, expiresIn = '20m') {
return this.jwtService.sign(
{ sub: user.id, passwordSha: this.createPasswordSha(user) },
{ expiresIn },
);
}
generatePasswordResetUrl(user: User) {
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
const url = new URL(`${instanceBaseUrl}/change-password`);
url.searchParams.append('token', this.generatePasswordResetToken(user));
url.searchParams.append('mfaEnabled', user.mfaEnabled.toString());
return url.toString();
}
async resolvePasswordResetToken(token: string): Promise<User | undefined> {
let decodedToken: JwtPayload & { passwordSha: string };
try {
decodedToken = this.jwtService.verify(token);
} catch (e) {
if (e instanceof TokenExpiredError) {
this.logger.debug('Reset password token expired', { token });
} else {
this.logger.debug('Error verifying token', { token });
}
return;
}
const user = await this.userRepository.findOne({
where: { id: decodedToken.sub },
relations: ['authIdentities'],
});
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID',
{ userId: decodedToken.sub, token },
);
return;
}
if (this.createPasswordSha(user) !== decodedToken.passwordSha) {
this.logger.debug('Password updated since this token was generated');
return;
}
return user;
}
private createPasswordSha({ password }: User) {
return createHash('sha256')
.update(password.slice(password.length / 2))
.digest('hex');
}
/** How many **milliseconds** before expiration should a JWT be renewed */
get jwtRefreshTimeout() {
const { jwtRefreshTimeoutHours, jwtSessionDurationHours } = config.get('userManagement');
if (jwtRefreshTimeoutHours === 0) {
return Math.floor(jwtSessionDurationHours * 0.25 * Time.hours.toMilliseconds);
} else {
return Math.floor(jwtRefreshTimeoutHours * Time.hours.toMilliseconds);
}
}
/** How many **seconds** is an issued JWT valid for */
get jwtExpiration() {
return config.get('userManagement.jwtSessionDurationHours') * Time.hours.toSeconds;
}
}

View file

@ -1,94 +1,12 @@
import type { Response } from 'express';
import { createHash } from 'crypto';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import type { JwtPayload, JwtToken } from '@/Interfaces';
import type { User } from '@db/entities/User';
import config from '@/config';
import { License } from '@/License';
import { Container } from 'typedi';
import { UserRepository } from '@db/repositories/user.repository';
import { JwtService } from '@/services/jwt.service';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { AuthError } from '@/errors/response-errors/auth.error';
import { ApplicationError } from 'n8n-workflow';
import type { Response } from 'express';
export function issueJWT(user: User): JwtToken {
const { id, email, password } = user;
const expiresInHours = config.getEnv('userManagement.jwtSessionDurationHours');
const expiresInSeconds = expiresInHours * Time.hours.toSeconds;
import type { User } from '@db/entities/User';
import { AuthService } from './auth.service';
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
const payload: JwtPayload = {
id,
email,
password: password ?? null,
};
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
!user.isOwner &&
!isWithinUsersLimit
) {
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (password) {
payload.password = createHash('sha256')
.update(password.slice(password.length / 2))
.digest('hex');
}
const signedToken = Container.get(JwtService).sign(payload, {
expiresIn: expiresInSeconds,
});
return {
token: signedToken,
expiresIn: expiresInSeconds,
};
}
export const createPasswordSha = (user: User) =>
createHash('sha256')
.update(user.password.slice(user.password.length / 2))
.digest('hex');
export async function resolveJwtContent(jwtPayload: JwtPayload): Promise<User> {
const user = await Container.get(UserRepository).findOne({
where: { id: jwtPayload.id },
});
let passwordHash = null;
if (user?.password) {
passwordHash = createPasswordSha(user);
}
// currently only LDAP users during synchronization
// can be set to disabled
if (user?.disabled) {
throw new AuthError('Unauthorized');
}
if (!user || jwtPayload.password !== passwordHash || user.email !== jwtPayload.email) {
// When owner hasn't been set up, the default user
// won't have email nor password (both equals null)
throw new ApplicationError('Invalid token content');
}
return user;
}
export async function resolveJwt(token: string): Promise<User> {
const jwtPayload: JwtPayload = Container.get(JwtService).verify(token, {
algorithms: ['HS256'],
});
return await resolveJwtContent(jwtPayload);
}
export async function issueCookie(res: Response, user: User): Promise<void> {
const userData = issueJWT(user);
res.cookie(AUTH_COOKIE_NAME, userData.token, {
maxAge: userData.expiresIn * Time.seconds.toMilliseconds,
httpOnly: true,
sameSite: 'lax',
});
// This method is still used by cloud hooks.
// DO NOT DELETE until the hooks have been updated
/** @deprecated Use `AuthService` instead */
export function issueCookie(res: Response, user: User) {
return Container.get(AuthService).issueCookie(res, user);
}

View file

@ -131,6 +131,7 @@ export const Time = {
},
days: {
toSeconds: 24 * 60 * 60,
toMilliseconds: 24 * 60 * 60 * 1000,
},
};

View file

@ -1,12 +1,12 @@
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import { Authorized, Get, Post, RestController } from '@/decorators';
import { issueCookie, resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Request, Response } from 'express';
import type { User } from '@db/entities/User';
import { LoginRequest, UserRequest } from '@/requests';
import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests';
import type { PublicUser } from '@/Interfaces';
import config from '@/config';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import { PostHogClient } from '@/posthog';
import {
@ -20,7 +20,6 @@ import { UserService } from '@/services/user.service';
import { MfaService } from '@/Mfa/mfa.service';
import { Logger } from '@/Logger';
import { AuthError } from '@/errors/response-errors/auth.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ApplicationError } from 'n8n-workflow';
@ -31,6 +30,7 @@ export class AuthController {
constructor(
private readonly logger: Logger,
private readonly internalHooks: InternalHooks,
private readonly authService: AuthService,
private readonly mfaService: MfaService,
private readonly userService: UserService,
private readonly license: License,
@ -96,7 +96,7 @@ export class AuthController {
}
}
await issueCookie(res, user);
this.authService.issueCookie(res, user);
void this.internalHooks.onUserLoginSuccess({
user,
authenticationMethod: usedAuthenticationMethod,
@ -112,45 +112,14 @@ export class AuthController {
throw new AuthError('Wrong username or password. Do you have caps lock on?');
}
/**
* Manually check the `n8n-auth` cookie.
*/
/** Check if the user is already logged in */
@Authorized()
@Get('/login')
async currentUser(req: Request, res: Response): Promise<PublicUser> {
// Manually check the existing cookie.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
let user: User;
if (cookieContents) {
// If logged in, return user
try {
user = await resolveJwt(cookieContents);
return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
}
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
throw new AuthError('Not logged in');
}
try {
user = await this.userRepository.findOneOrFail({ where: {} });
} catch (error) {
throw new InternalServerError(
'No users found in database - did you wipe the users table? Create at least one user.',
);
}
if (user.email || user.password) {
throw new InternalServerError('Invalid database state - user has password set.');
}
await issueCookie(res, user);
return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
return await this.userService.toPublic(req.user, {
posthog: this.postHog,
withScopes: true,
});
}
/**
@ -228,8 +197,8 @@ export class AuthController {
*/
@Authorized()
@Post('/logout')
logout(req: Request, res: Response) {
res.clearCookie(AUTH_COOKIE_NAME);
logout(_: Request, res: Response) {
this.authService.clearCookie(res);
return { loggedOut: true };
}

View file

@ -1,9 +1,9 @@
import { Response } from 'express';
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { Authorized, NoAuthRequired, Post, RequireGlobalScope, RestController } from '@/decorators';
import { issueCookie } from '@/auth/jwt';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { UserRequest } from '@/requests';
import { License } from '@/License';
@ -26,6 +26,7 @@ export class InvitationController {
private readonly logger: Logger,
private readonly internalHooks: InternalHooks,
private readonly externalHooks: ExternalHooks,
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly license: License,
private readonly passwordUtility: PasswordUtility,
@ -165,7 +166,7 @@ export class InvitationController {
const updatedUser = await this.userRepository.save(invitee, { transaction: false });
await issueCookie(res, updatedUser);
this.authService.issueCookie(res, updatedUser);
void this.internalHooks.onUserSignup(updatedUser, {
user_type: 'email',

View file

@ -2,10 +2,11 @@ import validator from 'validator';
import { plainToInstance } from 'class-transformer';
import { Response } from 'express';
import { randomBytes } from 'crypto';
import { AuthService } from '@/auth/auth.service';
import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators';
import { PasswordUtility } from '@/services/password.utility';
import { validateEntity } from '@/GenericHelpers';
import { issueCookie } from '@/auth/jwt';
import type { User } from '@db/entities/User';
import {
AuthenticatedRequest,
@ -14,7 +15,7 @@ import {
UserUpdatePayload,
} from '@/requests';
import type { PublicUser } from '@/Interfaces';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
import { isSamlLicensedAndEnabled } from '@/sso/saml/samlHelpers';
import { UserService } from '@/services/user.service';
import { Logger } from '@/Logger';
import { ExternalHooks } from '@/ExternalHooks';
@ -29,6 +30,7 @@ export class MeController {
private readonly logger: Logger,
private readonly externalHooks: ExternalHooks,
private readonly internalHooks: InternalHooks,
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository,
@ -84,7 +86,7 @@ export class MeController {
this.logger.info('User updated successfully', { userId });
await issueCookie(res, user);
this.authService.issueCookie(res, user);
const updatedKeys = Object.keys(payload);
void this.internalHooks.onUserUpdate({
@ -137,7 +139,7 @@ export class MeController {
const updatedUser = await this.userRepository.save(user, { transaction: false });
this.logger.info('Password updated successfully', { userId: user.id });
await issueCookie(res, updatedUser);
this.authService.issueCookie(res, updatedUser);
void this.internalHooks.onUserUpdate({
user: updatedUser,

View file

@ -1,19 +1,19 @@
import validator from 'validator';
import { Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { validateEntity } from '@/GenericHelpers';
import { Authorized, Post, RestController } from '@/decorators';
import { PasswordUtility } from '@/services/password.utility';
import { issueCookie } from '@/auth/jwt';
import { OwnerRequest } from '@/requests';
import { SettingsRepository } from '@db/repositories/settings.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { PostHogClient } from '@/posthog';
import { UserService } from '@/services/user.service';
import { Logger } from '@/Logger';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalHooks } from '@/InternalHooks';
import { UserRepository } from '@/databases/repositories/user.repository';
@Authorized('global:owner')
@RestController('/owner')
@ -22,6 +22,7 @@ export class OwnerController {
private readonly logger: Logger,
private readonly internalHooks: InternalHooks,
private readonly settingsRepository: SettingsRepository,
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly passwordUtility: PasswordUtility,
private readonly postHog: PostHogClient,
@ -89,7 +90,7 @@ export class OwnerController {
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId });
await issueCookie(res, owner);
this.authService.issueCookie(res, owner);
void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });

View file

@ -2,11 +2,11 @@ import { Response } from 'express';
import { rateLimit } from 'express-rate-limit';
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import { Get, Post, RestController } from '@/decorators';
import { PasswordUtility } from '@/services/password.utility';
import { UserManagementMailer } from '@/UserManagement/email';
import { PasswordResetRequest } from '@/requests';
import { issueCookie } from '@/auth/jwt';
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { UserService } from '@/services/user.service';
import { License } from '@/License';
@ -36,6 +36,7 @@ export class PasswordResetController {
private readonly externalHooks: ExternalHooks,
private readonly internalHooks: InternalHooks,
private readonly mailer: UserManagementMailer,
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly mfaService: MfaService,
private readonly urlService: UrlService,
@ -114,7 +115,7 @@ export class PasswordResetController {
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
}
const url = this.userService.generatePasswordResetUrl(user);
const url = this.authService.generatePasswordResetUrl(user);
const { id, firstName, lastName } = user;
try {
@ -163,7 +164,7 @@ export class PasswordResetController {
throw new BadRequestError('');
}
const user = await this.userService.resolvePasswordResetToken(token);
const user = await this.authService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
@ -197,7 +198,7 @@ export class PasswordResetController {
const validPassword = this.passwordUtility.validate(password);
const user = await this.userService.resolvePasswordResetToken(token);
const user = await this.authService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
if (user.mfaEnabled) {
@ -216,7 +217,7 @@ export class PasswordResetController {
this.logger.info('User password updated successfully', { userId: user.id });
await issueCookie(res, user);
this.authService.issueCookie(res, user);
void this.internalHooks.onUserUpdate({
user,

View file

@ -1,3 +1,6 @@
import { plainToInstance } from 'class-transformer';
import { AuthService } from '@/auth/auth.service';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
@ -22,7 +25,6 @@ import { AuthIdentity } from '@db/entities/AuthIdentity';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { plainToInstance } from 'class-transformer';
import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';
import { Logger } from '@/Logger';
@ -44,6 +46,7 @@ export class UsersController {
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly userRepository: UserRepository,
private readonly activeWorkflowRunner: ActiveWorkflowRunner,
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@ -116,7 +119,7 @@ export class UsersController {
throw new NotFoundError('User not found');
}
const link = this.userService.generatePasswordResetUrl(user);
const link = this.authService.generatePasswordResetUrl(user);
return { link };
}

View file

@ -1,9 +1,14 @@
import { Container } from 'typedi';
import { Router } from 'express';
import type { Application, Request, Response, RequestHandler } from 'express';
import type { Scope } from '@n8n/permissions';
import { ApplicationError } from 'n8n-workflow';
import type { Class } from 'n8n-core';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import type { BooleanLicenseFeature } from '@/Interfaces';
import { License } from '@/License';
import type { AuthenticatedRequest } from '@/requests';
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
import {
@ -15,7 +20,6 @@ import {
CONTROLLER_ROUTES,
} from './constants';
import type {
AuthRole,
AuthRoleMetadata,
Controller,
LicenseMetadata,
@ -23,23 +27,6 @@ import type {
RouteMetadata,
ScopeMetadata,
} from './types';
import type { BooleanLicenseFeature } from '@/Interfaces';
import { License } from '@/License';
import type { Scope } from '@n8n/permissions';
import { ApplicationError } from 'n8n-workflow';
export const createAuthMiddleware =
(authRole: AuthRole): RequestHandler =>
({ user }: AuthenticatedRequest, res, next) => {
if (authRole === 'none') return next();
if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' });
if (authRole === 'any' || authRole === user.role) return next();
res.status(403).json({ status: 'error', message: 'Unauthorized' });
};
export const createLicenseMiddleware =
(features: BooleanLicenseFeature[]): RequestHandler =>
@ -77,11 +64,6 @@ export const createGlobalScopeMiddleware =
return next();
};
const authFreeRoutes: string[] = [];
export const canSkipAuth = (method: string, path: string): boolean =>
authFreeRoutes.includes(`${method.toLowerCase()} ${path}`);
export const registerController = (app: Application, controllerClass: Class<object>) => {
const controller = Container.get(controllerClass as Class<Controller>);
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
@ -114,6 +96,8 @@ export const registerController = (app: Application, controllerClass: Class<obje
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler);
const authService = Container.get(AuthService);
routes.forEach(
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => {
const authRole = authRoles?.[handlerName] ?? authRoles?.['*'];
@ -123,14 +107,13 @@ export const registerController = (app: Application, controllerClass: Class<obje
await controller[handlerName](req, res);
router[method](
path,
...(authRole ? [createAuthMiddleware(authRole)] : []),
...(authRole ? [authService.createAuthMiddleware(authRole)] : []),
...(features ? [createLicenseMiddleware(features)] : []),
...(scopes ? [createGlobalScopeMiddleware(scopes)] : []),
...controllerMiddlewares,
...routeMiddlewares,
usesTemplates ? handler : send(handler),
);
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
},
);

View file

@ -1,114 +0,0 @@
import type { Application, NextFunction, Request, RequestHandler, Response } from 'express';
import { Container } from 'typedi';
import jwt from 'jsonwebtoken';
import passport from 'passport';
import { Strategy } from 'passport-jwt';
import { sync as globSync } from 'fast-glob';
import type { JwtPayload } from '@/Interfaces';
import type { AuthenticatedRequest } from '@/requests';
import { AUTH_COOKIE_NAME, EDITOR_UI_DIST_DIR } from '@/constants';
import { issueCookie, resolveJwtContent } from '@/auth/jwt';
import { canSkipAuth } from '@/decorators/registerController';
import { Logger } from '@/Logger';
import { JwtService } from '@/services/jwt.service';
import config from '@/config';
const jwtFromRequest = (req: Request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null;
};
const userManagementJwtAuth = (): RequestHandler => {
const jwtStrategy = new Strategy(
{
jwtFromRequest,
secretOrKey: Container.get(JwtService).jwtSecret,
},
async (jwtPayload: JwtPayload, done) => {
try {
const user = await resolveJwtContent(jwtPayload);
return done(null, user);
} catch (error) {
Container.get(Logger).debug('Failed to extract user from JWT payload', { jwtPayload });
return done(null, false, { message: 'User not found' });
}
},
);
passport.use(jwtStrategy);
return passport.initialize();
};
/**
* middleware to refresh cookie before it expires
*/
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);
if (cookieAuth && req.user && jwtRefreshTimeoutHours !== -1) {
const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number };
if (cookieContents.exp * 1000 - Date.now() < jwtRefreshTimeoutMilliSeconds) {
await issueCookie(res, req.user);
}
}
next();
}) satisfies RequestHandler;
const passportMiddleware = passport.authenticate('jwt', { session: false }) as RequestHandler;
const staticAssets = globSync(['**/*.html', '**/*.svg', '**/*.png', '**/*.ico'], {
cwd: EDITOR_UI_DIST_DIR,
});
// TODO: delete this
const isPostInvitationAccept = (req: Request, restEndpoint: string): boolean =>
req.method === 'POST' &&
new RegExp(`/${restEndpoint}/invitations/[\\w\\d-]*`).test(req.url) &&
req.url.includes('accept');
const isAuthExcluded = (url: string, ignoredEndpoints: Readonly<string[]>): boolean =>
!!ignoredEndpoints
.filter(Boolean) // skip empty paths
.find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`));
/**
* This sets up the auth middlewares in the correct order
*/
export const setupAuthMiddlewares = (
app: Application,
ignoredEndpoints: Readonly<string[]>,
restEndpoint: string,
) => {
app.use(userManagementJwtAuth());
app.use(async (req: Request, res: Response, next: NextFunction) => {
if (
// TODO: refactor me!!!
// skip authentication for preflight requests
req.method === 'OPTIONS' ||
staticAssets.includes(req.url.slice(1)) ||
canSkipAuth(req.method, req.path) ||
isAuthExcluded(req.url, ignoredEndpoints) ||
req.url.startsWith(`/${restEndpoint}/settings`) ||
isPostInvitationAccept(req, restEndpoint)
) {
return next();
}
return passportMiddleware(req, res, next);
});
app.use(refreshExpiringCookie);
};

View file

@ -1,4 +1,3 @@
export * from './auth';
export * from './bodyParser';
export * from './cors';
export * from './listQuery';

View file

@ -7,14 +7,13 @@ import { Server as WSServer } from 'ws';
import { parse as parseUrl } from 'url';
import { Container, Service } from 'typedi';
import config from '@/config';
import { resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME } from '@/constants';
import { SSEPush } from './sse.push';
import { WebSocketPush } from './websocket.push';
import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
import type { IPushDataType } from '@/Interfaces';
import type { User } from '@db/entities/User';
import { OnShutdown } from '@/decorators/OnShutdown';
import { AuthService } from '@/auth/auth.service';
const useWebSockets = config.getEnv('push.backend') === 'websocket';
@ -120,27 +119,15 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => {
}
return;
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? '';
const user = await resolveJwt(authCookie);
req.userId = user.id;
} catch (error) {
if (ws) {
ws.send(`Unauthorized: ${(error as Error).message}`);
ws.close(1008);
} else {
res.status(401).send('Unauthorized');
}
return;
}
next();
};
const push = Container.get(Push);
const authService = Container.get(AuthService);
app.use(
endpoint,
authService.createAuthMiddleware('any'),
pushValidationMiddleware,
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) => push.handleRequest(req, res),
);

View file

@ -1,10 +1,12 @@
import type { User } from '@db/entities/User';
import type { Request, Response } from 'express';
import type { Response } from 'express';
import type { WebSocket } from 'ws';
import type { User } from '@db/entities/User';
import type { AuthenticatedRequest } from '@/requests';
// TODO: move all push related types here
export type PushRequest = Request<{}, {}, {}, { sessionId: string }>;
export type PushRequest = AuthenticatedRequest<{}, {}, {}, { sessionId: string }>;
export type SSEPushRequest = PushRequest & { ws: undefined; userId: User['id'] };
export type WebSocketPushRequest = PushRequest & { ws: WebSocket; userId: User['id'] };

View file

@ -1,3 +1,4 @@
import type { ParsedQs } from 'qs';
import type express from 'express';
import type {
BannerName,
@ -20,6 +21,17 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { WorkflowHistory } from '@db/entities/WorkflowHistory';
export type AsyncRequestHandler<
Params = Record<string, string>,
ResBody = unknown,
ReqBody = unknown,
ReqQuery = ParsedQs,
> = (
req: express.Request<Params, ResBody, ReqBody, ReqQuery>,
res: express.Response<ResBody>,
next: () => void,
) => Promise<void>;
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
@IsEmail()
email: string;
@ -62,8 +74,12 @@ export type AuthenticatedRequest<
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
> = Omit<express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user'> & {
> = Omit<
express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>,
'user' | 'cookies'
> & {
user: User;
cookies: Record<string, string | undefined>;
};
// ----------------------------------

View file

@ -1,17 +1,15 @@
import { Container, Service } from 'typedi';
import { type AssignableRole, User } from '@db/entities/User';
import type { IUserSettings } from 'n8n-workflow';
import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { type AssignableRole, User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import type { PublicUser } from '@/Interfaces';
import type { PostHogClient } from '@/posthog';
import { type JwtPayload, JwtService } from './jwt.service';
import { TokenExpiredError } from 'jsonwebtoken';
import { Logger } from '@/Logger';
import { createPasswordSha } from '@/auth/jwt';
import { UserManagementMailer } from '@/UserManagement/email';
import { InternalHooks } from '@/InternalHooks';
import { UrlService } from '@/services/url.service';
import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import type { UserRequest } from '@/requests';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@ -20,7 +18,6 @@ export class UserService {
constructor(
private readonly logger: Logger,
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
private readonly mailer: UserManagementMailer,
private readonly urlService: UrlService,
) {}
@ -39,57 +36,6 @@ export class UserService {
return await this.userRepository.update(userId, { settings: { ...settings, ...newSettings } });
}
generatePasswordResetToken(user: User, expiresIn = '20m') {
return this.jwtService.sign(
{ sub: user.id, passwordSha: createPasswordSha(user) },
{ expiresIn },
);
}
generatePasswordResetUrl(user: User) {
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
const url = new URL(`${instanceBaseUrl}/change-password`);
url.searchParams.append('token', this.generatePasswordResetToken(user));
url.searchParams.append('mfaEnabled', user.mfaEnabled.toString());
return url.toString();
}
async resolvePasswordResetToken(token: string): Promise<User | undefined> {
let decodedToken: JwtPayload & { passwordSha: string };
try {
decodedToken = this.jwtService.verify(token);
} catch (e) {
if (e instanceof TokenExpiredError) {
this.logger.debug('Reset password token expired', { token });
} else {
this.logger.debug('Error verifying token', { token });
}
return;
}
const user = await this.userRepository.findOne({
where: { id: decodedToken.sub },
relations: ['authIdentities'],
});
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID',
{ userId: decodedToken.sub, token },
);
return;
}
if (createPasswordSha(user) !== decodedToken.passwordSha) {
this.logger.debug('Password updated since this token was generated');
return;
}
return user;
}
async toPublic(
user: User,
options?: {

View file

@ -1,4 +1,8 @@
import express from 'express';
import { validate } from 'class-validator';
import type { PostBindingContext } from 'samlify/types/src/entity';
import url from 'url';
import {
Authorized,
Get,
@ -7,20 +11,16 @@ import {
RestController,
RequireGlobalScope,
} from '@/decorators';
import { SamlUrls } from '../constants';
import {
samlLicensedAndEnabledMiddleware,
samlLicensedMiddleware,
} from '../middleware/samlEnabledMiddleware';
import { SamlService } from '../saml.service.ee';
import { SamlConfiguration } from '../types/requests';
import { getInitSSOFormView } from '../views/initSsoPost';
import { issueCookie } from '@/auth/jwt';
import { validate } from 'class-validator';
import type { PostBindingContext } from 'samlify/types/src/entity';
import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../samlHelpers';
import type { SamlLoginBinding } from '../types';
import { AuthService } from '@/auth/auth.service';
import { AuthenticatedRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
import querystring from 'querystring';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { AuthError } from '@/errors/response-errors/auth.error';
import { UrlService } from '@/services/url.service';
import { SamlUrls } from '../constants';
import {
getServiceProviderConfigTestReturnUrl,
getServiceProviderEntityId,
@ -28,17 +28,21 @@ import {
} from '../serviceProvider.ee';
import { getSamlConnectionTestSuccessView } from '../views/samlConnectionTestSuccess';
import { getSamlConnectionTestFailedView } from '../views/samlConnectionTestFailed';
import { InternalHooks } from '@/InternalHooks';
import url from 'url';
import querystring from 'querystring';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { AuthError } from '@/errors/response-errors/auth.error';
import { UrlService } from '@/services/url.service';
import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../samlHelpers';
import type { SamlLoginBinding } from '../types';
import {
samlLicensedAndEnabledMiddleware,
samlLicensedMiddleware,
} from '../middleware/samlEnabledMiddleware';
import { SamlService } from '../saml.service.ee';
import { SamlConfiguration } from '../types/requests';
import { getInitSSOFormView } from '../views/initSsoPost';
@Authorized()
@RestController('/sso/saml')
export class SamlController {
constructor(
private readonly authService: AuthService,
private readonly samlService: SamlService,
private readonly urlService: UrlService,
private readonly internalHooks: InternalHooks,
@ -46,7 +50,7 @@ export class SamlController {
@NoAuthRequired()
@Get(SamlUrls.metadata)
async getServiceProviderMetadata(req: express.Request, res: express.Response) {
async getServiceProviderMetadata(_: express.Request, res: express.Response) {
return res
.header('Content-Type', 'text/xml')
.send(this.samlService.getServiceProviderInstance().getMetadata());
@ -147,7 +151,7 @@ export class SamlController {
});
// Only sign in user if SAML is enabled, otherwise treat as test connection
if (isSamlLicensedAndEnabled()) {
await issueCookie(res, loginResult.authenticatedUser);
this.authService.issueCookie(res, loginResult.authenticatedUser);
if (loginResult.onboardingRequired) {
return res.redirect(this.urlService.getInstanceBaseUrl() + SamlUrls.samlOnboarding);
} else {

View file

@ -157,18 +157,6 @@ describe('GET /login', () => {
expect(authToken).toBeUndefined();
});
test('should return cookie if UM is disabled and no cookie is already set', async () => {
await createUserShell('global:owner');
await utils.setInstanceOwnerSetUp(false);
const response = await testServer.authlessAgent.get('/login');
expect(response.statusCode).toBe(200);
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
});
test('should return 401 Unauthorized if invalid cookie', async () => {
testServer.authlessAgent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`);

View file

@ -18,7 +18,6 @@ describe('Auth Middleware', () => {
['PATCH', '/me/password'],
['POST', '/me/survey'],
['POST', '/owner/setup'],
['GET', '/non-existent'],
];
/** Routes requiring a valid `n8n-auth` cookie for an owner. */

View file

@ -453,7 +453,7 @@ describe('POST /ldap/sync', () => {
await authOwnerAgent.post('/ldap/sync').send({ type: 'live' });
const response = await testServer.authAgentFor(member).get('/login');
expect(response.body.code).toBe(401);
expect(response.status).toBe(401);
});
});
});

View file

@ -300,10 +300,6 @@ describe('Member', () => {
});
describe('Owner', () => {
beforeEach(async () => {
await utils.setInstanceOwnerSetUp(true);
});
test('PATCH /me should succeed with valid inputs', async () => {
const owner = await createUser({ role: 'global:owner' });
const authOwnerAgent = testServer.authAgentFor(owner);

View file

@ -1,14 +1,16 @@
import Container from 'typedi';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import { randomPassword } from '@/Ldap/helpers';
import { TOTPService } from '@/Mfa/totp.service';
import { UserService } from '@/services/user.service';
import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random';
import * as testDb from '../shared/testDb';
import * as utils from '../shared/utils';
import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random';
import { createUser, createUserWithMfaEnabled } from '../shared/db/users';
import { UserRepository } from '@db/repositories/user.repository';
jest.mock('@/telemetry');
@ -241,7 +243,7 @@ describe('Change password with MFA enabled', () => {
config.set('userManagement.jwtSecret', randomString(5, 10));
const resetPasswordToken = Container.get(UserService).generatePasswordResetToken(user);
const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user);
const mfaToken = new TOTPService().generateTOTP(rawSecret);

View file

@ -3,13 +3,13 @@ import { compare } from 'bcryptjs';
import { Container } from 'typedi';
import { mock } from 'jest-mock-extended';
import { AuthService } from '@/auth/auth.service';
import { License } from '@/License';
import config from '@/config';
import type { User } from '@db/entities/User';
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { ExternalHooks } from '@/ExternalHooks';
import { JwtService } from '@/services/jwt.service';
import { UserService } from '@/services/user.service';
import { UserManagementMailer } from '@/UserManagement/email';
import { UserRepository } from '@db/repositories/user.repository';
@ -35,7 +35,7 @@ const externalHooks = mockInstance(ExternalHooks);
const mailer = mockInstance(UserManagementMailer, { isEmailSetUp: true });
const testServer = setupTestServer({ endpointGroups: ['passwordReset'] });
const jwtService = Container.get(JwtService);
let userService: UserService;
let authService: AuthService;
beforeEach(async () => {
await testDb.truncate(['User']);
@ -43,7 +43,7 @@ beforeEach(async () => {
member = await createUser({ role: 'global:member' });
externalHooks.run.mockReset();
jest.replaceProperty(mailer, 'isEmailSetUp', true);
userService = Container.get(UserService);
authService = Container.get(AuthService);
});
describe('POST /forgot-password', () => {
@ -126,7 +126,7 @@ describe('POST /forgot-password', () => {
describe('GET /resolve-password-token', () => {
test('should succeed with valid inputs', async () => {
const resetPasswordToken = userService.generatePasswordResetToken(owner);
const resetPasswordToken = authService.generatePasswordResetToken(owner);
const response = await testServer.authlessAgent
.get('/resolve-password-token')
@ -158,7 +158,7 @@ describe('GET /resolve-password-token', () => {
});
test('should fail if token is expired', async () => {
const resetPasswordToken = userService.generatePasswordResetToken(owner, '-1h');
const resetPasswordToken = authService.generatePasswordResetToken(owner, '-1h');
const response = await testServer.authlessAgent
.get('/resolve-password-token')
@ -169,7 +169,7 @@ describe('GET /resolve-password-token', () => {
test('should fail after password has changed', async () => {
const updatedUser = mock<User>({ ...owner, password: 'another-password' });
const resetPasswordToken = userService.generatePasswordResetToken(updatedUser);
const resetPasswordToken = authService.generatePasswordResetToken(updatedUser);
const response = await testServer.authlessAgent
.get('/resolve-password-token')
@ -183,7 +183,7 @@ describe('POST /change-password', () => {
const passwordToStore = randomValidPassword();
test('should succeed with valid inputs', async () => {
const resetPasswordToken = userService.generatePasswordResetToken(owner);
const resetPasswordToken = authService.generatePasswordResetToken(owner);
const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken,
userId: owner.id,
@ -213,7 +213,7 @@ describe('POST /change-password', () => {
});
test('should fail with invalid inputs', async () => {
const resetPasswordToken = userService.generatePasswordResetToken(owner);
const resetPasswordToken = authService.generatePasswordResetToken(owner);
const invalidPayloads = [
{ token: uuid() },
@ -247,7 +247,7 @@ describe('POST /change-password', () => {
});
test('should fail when token has expired', async () => {
const resetPasswordToken = userService.generatePasswordResetToken(owner, '-1h');
const resetPasswordToken = authService.generatePasswordResetToken(owner, '-1h');
const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken,
@ -263,7 +263,7 @@ describe('POST /change-password', () => {
test('owner should be able to reset its password when quota:users = 1', async () => {
jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1);
const resetPasswordToken = userService.generatePasswordResetToken(owner);
const resetPasswordToken = authService.generatePasswordResetToken(owner);
const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken,
userId: owner.id,
@ -292,7 +292,7 @@ describe('POST /change-password', () => {
test('member should not be able to reset its password when quota:users = 1', async () => {
jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1);
const resetPasswordToken = userService.generatePasswordResetToken(member);
const resetPasswordToken = authService.generatePasswordResetToken(member);
const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken,
userId: member.id,

View file

@ -4,14 +4,6 @@ export const REST_PATH_SEGMENT = config.getEnv('endpoints.rest');
export const PUBLIC_API_REST_PATH_SEGMENT = config.getEnv('publicApi.path');
export const AUTHLESS_ENDPOINTS: Readonly<string[]> = [
'healthz',
'metrics',
config.getEnv('endpoints.webhook'),
config.getEnv('endpoints.webhookWaiting'),
config.getEnv('endpoints.webhookTest'),
];
export const SUCCESS_RESPONSE_BODY = {
data: {
success: true,

View file

@ -35,7 +35,6 @@ type EndpointGroup =
| 'debug';
export interface SetupProps {
applyAuth?: boolean;
endpointGroups?: EndpointGroup[];
enabledFeatures?: BooleanLicenseFeature[];
quotas?: Partial<{ [K in NumericLicenseFeature]: number }>;

View file

@ -8,9 +8,8 @@ import { URL } from 'url';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import type { User } from '@db/entities/User';
import { issueJWT } from '@/auth/jwt';
import { registerController } from '@/decorators';
import { rawBodyReader, bodyParser, setupAuthMiddlewares } from '@/middlewares';
import { rawBodyReader, bodyParser } from '@/middlewares';
import { PostHogClient } from '@/posthog';
import { Push } from '@/push';
import { License } from '@/License';
@ -19,9 +18,10 @@ import { InternalHooks } from '@/InternalHooks';
import { mockInstance } from '../../../shared/mocking';
import * as testDb from '../../shared/testDb';
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
import { PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
import type { SetupProps, TestServer } from '../types';
import { LicenseMocker } from '../license';
import { AuthService } from '@/auth/auth.service';
/**
* Plugin to prefix a path segment into a request URL pathname.
@ -47,7 +47,7 @@ function createAgent(app: express.Application, options?: { auth: boolean; user:
const agent = request.agent(app);
void agent.use(prefix(REST_PATH_SEGMENT));
if (options?.auth && options?.user) {
const { token } = issueJWT(options.user);
const token = Container.get(AuthService).issueJWT(options.user);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
}
return agent;
@ -67,7 +67,6 @@ function publicApiAgent(
export const setupTestServer = ({
endpointGroups,
applyAuth = true,
enabledFeatures,
quotas,
}: SetupProps): TestServer => {
@ -104,15 +103,11 @@ export const setupTestServer = ({
});
}
const enablePublicAPI = endpointGroups?.includes('publicApi');
if (applyAuth && !enablePublicAPI) {
setupAuthMiddlewares(app, AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT);
}
if (!endpointGroups) return;
app.use(bodyParser);
const enablePublicAPI = endpointGroups?.includes('publicApi');
if (enablePublicAPI) {
const { loadPublicApiVersions } = await import('@/PublicApi');
const { apiRouters } = await loadPublicApiVersions(PUBLIC_API_REST_PATH_SEGMENT);

View file

@ -28,6 +28,7 @@ describe('EnterpriseWorkflowService', () => {
Container.get(SharedWorkflowRepository),
Container.get(WorkflowRepository),
Container.get(CredentialsRepository),
mock(),
);
});

View file

@ -1,6 +1,7 @@
import config from '@/config';
import { NodeMailer } from '@/UserManagement/email/NodeMailer';
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
import { mock } from 'jest-mock-extended';
describe('UserManagementMailer', () => {
describe('expect NodeMailer.verifyConnection', () => {
@ -20,7 +21,7 @@ describe('UserManagementMailer', () => {
});
test('not be called when SMTP not set up', async () => {
const userManagementMailer = new UserManagementMailer();
const userManagementMailer = new UserManagementMailer(mock(), mock(), mock());
// NodeMailer.verifyConnection gets called only explicitly
await expect(async () => await userManagementMailer.verifyConnection()).rejects.toThrow();
@ -32,7 +33,7 @@ describe('UserManagementMailer', () => {
config.set('userManagement.emails.smtp.host', 'host');
config.set('userManagement.emails.mode', 'smtp');
const userManagementMailer = new UserManagementMailer();
const userManagementMailer = new UserManagementMailer(mock(), mock(), mock());
// NodeMailer.verifyConnection gets called only explicitly
expect(async () => await userManagementMailer.verifyConnection()).not.toThrow();
});

View file

@ -0,0 +1,292 @@
import jwt from 'jsonwebtoken';
import { mock } from 'jest-mock-extended';
import { type NextFunction, type Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { AUTH_COOKIE_NAME, Time } from '@/constants';
import type { User } from '@db/entities/User';
import type { UserRepository } from '@db/repositories/user.repository';
import { JwtService } from '@/services/jwt.service';
import type { UrlService } from '@/services/url.service';
import type { AuthenticatedRequest } from '@/requests';
describe('AuthService', () => {
config.set('userManagement.jwtSecret', 'random-secret');
const userData = {
id: '123',
email: 'test@example.com',
password: 'passwordHash',
disabled: false,
mfaEnabled: false,
};
const validToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInBhc3N3b3JkIjoiMzE1MTNjNWE5ZTNjNWFmZTVjMDZkNTY3NWFjZTc0ZThiYzNmYWRkOTc0NGFiNWQ4OWMzMTFmMmE2MmNjYmQzOSIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA3MzU1NDI1fQ.mtXKUwQDHOhiHn0YNuCeybmxevtNG6LXTAv_sQL63Zc';
const user = mock<User>(userData);
const jwtService = new JwtService(mock());
const urlService = mock<UrlService>();
const userRepository = mock<UserRepository>();
const authService = new AuthService(mock(), mock(), jwtService, urlService, userRepository);
jest.useFakeTimers();
const now = new Date('2024-02-01T01:23:45.678Z');
beforeEach(() => {
jest.clearAllMocks();
jest.setSystemTime(now);
config.set('userManagement.jwtSessionDurationHours', 168);
config.set('userManagement.jwtRefreshTimeoutHours', 0);
});
describe('createAuthMiddleware', () => {
const req = mock<AuthenticatedRequest>({ cookies: {}, user: undefined });
const res = mock<Response>();
const next = jest.fn() as NextFunction;
beforeEach(() => {
res.status.mockReturnThis();
});
describe('authRole = none', () => {
const authMiddleware = authService.createAuthMiddleware('none');
it('should just skips auth checks', async () => {
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('authRole = any', () => {
const authMiddleware = authService.createAuthMiddleware('any');
it('should 401 if no cookie is set', async () => {
req.cookies[AUTH_COOKIE_NAME] = undefined;
await authMiddleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
it('should 401 and clear the cookie if the JWT is expired', async () => {
req.cookies[AUTH_COOKIE_NAME] = validToken;
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
await authMiddleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
});
it('should refresh the cookie before it expires', async () => {
req.cookies[AUTH_COOKIE_NAME] = validToken;
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
userRepository.findOne.mockResolvedValue(user);
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
httpOnly: true,
maxAge: 604800000,
sameSite: 'lax',
});
});
});
describe('authRole = global:owner', () => {
const authMiddleware = authService.createAuthMiddleware('global:owner');
it('should 403 if the user does not have the correct role', async () => {
req.cookies[AUTH_COOKIE_NAME] = validToken;
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
userRepository.findOne.mockResolvedValue(
mock<User>({ ...userData, role: 'global:member' }),
);
await authMiddleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
});
describe('issueJWT', () => {
describe('when not setting userManagement.jwtSessionDuration', () => {
it('should default to expire in 7 days', () => {
const defaultInSeconds = 7 * Time.days.toSeconds;
const token = authService.issueJWT(user);
expect(authService.jwtExpiration).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 testDurationHours = 1;
const testDurationSeconds = testDurationHours * Time.hours.toSeconds;
it('should apply it to tokens', () => {
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
const token = authService.issueJWT(user);
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);
});
});
});
describe('resolveJwt', () => {
const res = mock<Response>();
it('should throw on invalid tokens', async () => {
await expect(authService.resolveJwt('random-string', res)).rejects.toThrow('jwt malformed');
expect(res.cookie).not.toHaveBeenCalled();
});
it('should throw on expired tokens', async () => {
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
await expect(authService.resolveJwt(validToken, res)).rejects.toThrow('jwt expired');
expect(res.cookie).not.toHaveBeenCalled();
});
it('should throw on tampered tokens', async () => {
const [header, payload, signature] = validToken.split('.');
const tamperedToken = [header, payload, signature + '123'].join('.');
await expect(authService.resolveJwt(tamperedToken, res)).rejects.toThrow('invalid signature');
expect(res.cookie).not.toHaveBeenCalled();
});
test.each([
['no user is found', null],
['the user is disabled', { ...userData, disabled: true }],
[
'user password does not match the one on the token',
{ ...userData, password: 'something else' },
],
[
'user email does not match the one on the token',
{ ...userData, email: 'someone@example.com' },
],
])('should throw if %s', async (_, data) => {
userRepository.findOne.mockResolvedValueOnce(data && mock<User>(data));
await expect(authService.resolveJwt(validToken, res)).rejects.toThrow('Unauthorized');
expect(res.cookie).not.toHaveBeenCalled();
});
it('should refresh the cookie before it expires', async () => {
userRepository.findOne.mockResolvedValue(user);
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).not.toHaveBeenCalled();
jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
httpOnly: true,
maxAge: 604800000,
sameSite: 'lax',
});
});
it('should refresh the cookie only if less than 1/4th of time is left', async () => {
userRepository.findOne.mockResolvedValue(user);
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).not.toHaveBeenCalled();
jest.advanceTimersByTime(5 * Time.days.toMilliseconds);
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * Time.days.toMilliseconds);
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).toHaveBeenCalled();
});
it('should not refresh the cookie if jwtRefreshTimeoutHours is set to -1', async () => {
config.set('userManagement.jwtRefreshTimeoutHours', -1);
userRepository.findOne.mockResolvedValue(user);
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).not.toHaveBeenCalled();
jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
expect(await authService.resolveJwt(validToken, res)).toEqual(user);
expect(res.cookie).not.toHaveBeenCalled();
});
});
describe('generatePasswordResetUrl', () => {
it('should generate a valid url', () => {
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.instance');
const url = authService.generatePasswordResetUrl(user);
expect(url).toEqual(
'https://n8n.instance/change-password?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJwYXNzd29yZFNoYSI6IjMxNTEzYzVhOWUzYzVhZmU1YzA2ZDU2NzVhY2U3NGU4YmMzZmFkZDk3NDRhYjVkODljMzExZjJhNjJjY2JkMzkiLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNjc1MTgyNX0.wsdEpbK2zhFucaPwga7f8EOcwiJcv0iW23HcnvJs-s8&mfaEnabled=false',
);
});
});
describe('generatePasswordResetToken', () => {
it('should generate valid password-reset tokens', () => {
const token = authService.generatePasswordResetToken(user);
const decoded = jwt.decode(token) as jwt.JwtPayload;
if (!decoded.exp) fail('Token does not contain expiry');
if (!decoded.iat) fail('Token does not contain issued-at');
expect(decoded.sub).toEqual(user.id);
expect(decoded.exp - decoded.iat).toEqual(1200); // Expires in 20 minutes
expect(decoded.passwordSha).toEqual(
'31513c5a9e3c5afe5c06d5675ace74e8bc3fadd9744ab5d89c311f2a62ccbd39',
);
});
});
describe('resolvePasswordResetToken', () => {
it('should not return a user if the token in invalid', async () => {
const resolvedUser = await authService.resolvePasswordResetToken('invalid-token');
expect(resolvedUser).toBeUndefined();
});
it('should not return a user if the token in expired', async () => {
const token = authService.generatePasswordResetToken(user, '-1h');
const resolvedUser = await authService.resolvePasswordResetToken(token);
expect(resolvedUser).toBeUndefined();
});
it('should not return a user if the user does not exist in the DB', async () => {
userRepository.findOne.mockResolvedValueOnce(null);
const token = authService.generatePasswordResetToken(user);
const resolvedUser = await authService.resolvePasswordResetToken(token);
expect(resolvedUser).toBeUndefined();
});
it('should not return a user if the password sha does not match', async () => {
const token = authService.generatePasswordResetToken(user);
const updatedUser = Object.create(user);
updatedUser.password = 'something-else';
userRepository.findOne.mockResolvedValueOnce(updatedUser);
const resolvedUser = await authService.resolvePasswordResetToken(token);
expect(resolvedUser).toBeUndefined();
});
it('should not return the user if all checks pass', async () => {
const token = authService.generatePasswordResetToken(user);
userRepository.findOne.mockResolvedValueOnce(user);
const resolvedUser = await authService.resolvePasswordResetToken(token);
expect(resolvedUser).toEqual(user);
});
});
});

View file

@ -1,61 +0,0 @@
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);
});
});
});

View file

@ -1,34 +1,37 @@
import type { CookieOptions, Response } from 'express';
import { anyObject, captor, mock } from 'jest-mock-extended';
import Container from 'typedi';
import type { Response } from 'express';
import { anyObject, mock } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import type { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { OwnerController } from '@/controllers/owner.controller';
import type { User } from '@db/entities/User';
import type { SettingsRepository } from '@db/repositories/settings.repository';
import config from '@/config';
import type { OwnerRequest } from '@/requests';
import { OwnerController } from '@/controllers/owner.controller';
import { AUTH_COOKIE_NAME } from '@/constants';
import { UserService } from '@/services/user.service';
import type { UserRepository } from '@db/repositories/user.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { InternalHooks } from '@/InternalHooks';
import { License } from '@/License';
import type { OwnerRequest } from '@/requests';
import type { UserService } from '@/services/user.service';
import { PasswordUtility } from '@/services/password.utility';
import { mockInstance } from '../../shared/mocking';
import { badPasswords } from '../shared/testData';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { PasswordUtility } from '@/services/password.utility';
import Container from 'typedi';
import type { InternalHooks } from '@/InternalHooks';
import { UserRepository } from '@/databases/repositories/user.repository';
describe('OwnerController', () => {
const configGetSpy = jest.spyOn(config, 'getEnv');
const internalHooks = mock<InternalHooks>();
const userService = mockInstance(UserService);
const userRepository = mockInstance(UserRepository);
const authService = mock<AuthService>();
const userService = mock<UserService>();
const userRepository = mock<UserRepository>();
const settingsRepository = mock<SettingsRepository>();
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
const controller = new OwnerController(
mock(),
internalHooks,
settingsRepository,
authService,
userService,
Container.get(PasswordUtility),
mock(),
@ -96,11 +99,7 @@ describe('OwnerController', () => {
await controller.setupOwner(req, res);
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
const cookieOptions = captor<CookieOptions>();
expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions);
expect(cookieOptions.value.httpOnly).toBe(true);
expect(cookieOptions.value.sameSite).toBe('lax');
expect(authService.issueCookie).toHaveBeenCalledWith(res, user);
});
});
});

View file

@ -1,162 +0,0 @@
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',
});
});
});
});

View file

@ -1,22 +1,13 @@
import Container from 'typedi';
import jwt from 'jsonwebtoken';
import { Logger } from '@/Logger';
import config from '@/config';
import { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import { UserService } from '@/services/user.service';
import { mockInstance } from '../../shared/mocking';
import { mock } from 'jest-mock-extended';
import { v4 as uuid } from 'uuid';
import { InternalHooks } from '@/InternalHooks';
import { User } from '@db/entities/User';
import { UserService } from '@/services/user.service';
import { UrlService } from '@/services/url.service';
describe('UserService', () => {
config.set('userManagement.jwtSecret', 'random-secret');
mockInstance(Logger);
mockInstance(InternalHooks);
const userRepository = mockInstance(UserRepository);
const userService = Container.get(UserService);
const urlService = new UrlService();
const userService = new UserService(mock(), mock(), mock(), urlService);
const commonMockUser = Object.assign(new User(), {
id: uuid(),
@ -75,66 +66,4 @@ describe('UserService', () => {
expect(url.searchParams.get('inviteeId')).toBe(secondUser.id);
});
});
describe('generatePasswordResetToken', () => {
it('should generate valid password-reset tokens', () => {
const token = userService.generatePasswordResetToken(commonMockUser);
const decoded = jwt.decode(token) as jwt.JwtPayload;
if (!decoded.exp) fail('Token does not contain expiry');
if (!decoded.iat) fail('Token does not contain issued-at');
expect(decoded.sub).toEqual(commonMockUser.id);
expect(decoded.exp - decoded.iat).toEqual(1200); // Expires in 20 minutes
expect(decoded.passwordSha).toEqual(
'31513c5a9e3c5afe5c06d5675ace74e8bc3fadd9744ab5d89c311f2a62ccbd39',
);
});
});
describe('resolvePasswordResetToken', () => {
it('should not return a user if the token in invalid', async () => {
const user = await userService.resolvePasswordResetToken('invalid-token');
expect(user).toBeUndefined();
});
it('should not return a user if the token in expired', async () => {
const token = userService.generatePasswordResetToken(commonMockUser, '-1h');
const user = await userService.resolvePasswordResetToken(token);
expect(user).toBeUndefined();
});
it('should not return a user if the user does not exist in the DB', async () => {
userRepository.findOne.mockResolvedValueOnce(null);
const token = userService.generatePasswordResetToken(commonMockUser);
const user = await userService.resolvePasswordResetToken(token);
expect(user).toBeUndefined();
});
it('should not return a user if the password sha does not match', async () => {
const token = userService.generatePasswordResetToken(commonMockUser);
const updatedUser = Object.create(commonMockUser);
updatedUser.password = 'something-else';
userRepository.findOne.mockResolvedValueOnce(updatedUser);
const user = await userService.resolvePasswordResetToken(token);
expect(user).toBeUndefined();
});
it('should not return the user if all checks pass', async () => {
const token = userService.generatePasswordResetToken(commonMockUser);
userRepository.findOne.mockResolvedValueOnce(commonMockUser);
const user = await userService.resolvePasswordResetToken(token);
expect(user).toEqual(commonMockUser);
});
});
});

View file

@ -548,15 +548,6 @@ importers:
p-lazy:
specifier: 3.1.0
version: 3.1.0
passport:
specifier: 0.6.0
version: 0.6.0
passport-cookie:
specifier: 1.0.9
version: 1.0.9
passport-jwt:
specifier: 4.0.1
version: 4.0.1
pg:
specifier: 8.11.3
version: 8.11.3
@ -675,18 +666,12 @@ importers:
'@types/lodash':
specifier: ^4.14.195
version: 4.14.195
'@types/passport-jwt':
specifier: ^3.0.6
version: 3.0.7
'@types/psl':
specifier: ^1.1.0
version: 1.1.0
'@types/replacestream':
specifier: ^4.0.1
version: 4.0.1
'@types/send':
specifier: ^0.17.1
version: 0.17.1
'@types/shelljs':
specifier: ^0.8.11
version: 0.8.11
@ -10024,10 +10009,6 @@ packages:
resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==}
dev: true
/@types/mime@1.3.2:
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
dev: true
/@types/mime@3.0.1:
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
@ -10082,27 +10063,6 @@ packages:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: true
/@types/passport-jwt@3.0.7:
resolution: {integrity: sha512-qRQ4qlww1Yhs3IaioDKrsDNmKy6gLDLgFsGwpCnc2YqWovO2Oxu9yCQdWHMJafQ7UIuOba4C4/TNXcGkQfEjlQ==}
dependencies:
'@types/express': 4.17.14
'@types/jsonwebtoken': 9.0.1
'@types/passport-strategy': 0.2.35
dev: true
/@types/passport-strategy@0.2.35:
resolution: {integrity: sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==}
dependencies:
'@types/express': 4.17.14
'@types/passport': 1.0.11
dev: true
/@types/passport@1.0.11:
resolution: {integrity: sha512-pz1cx9ptZvozyGKKKIPLcVDVHwae4hrH5d6g5J+DkMRRjR3cVETb4jMabhXAUbg3Ov7T22nFHEgaK2jj+5CBpw==}
dependencies:
'@types/express': 4.17.14
dev: true
/@types/phoenix@1.6.4:
resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==}
dev: false
@ -10183,13 +10143,6 @@ packages:
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
dev: true
/@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
dependencies:
'@types/mime': 1.3.2
'@types/node': 18.16.16
dev: true
/@types/serve-static@1.15.0:
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
dependencies:
@ -20984,34 +20937,6 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/passport-cookie@1.0.9:
resolution: {integrity: sha512-8a6foX2bbGoJzup0RAiNcC2tTqzYS46RQEK3Z4u8p86wesPUjgDaji3C7+5j4TGyCq4ZoOV+3YLw1Hy6cV6kyw==}
engines: {node: '>= 0.10.0'}
dependencies:
passport-strategy: 1.0.0
dev: false
/passport-jwt@4.0.1:
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
dependencies:
jsonwebtoken: 9.0.0
passport-strategy: 1.0.0
dev: false
/passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'}
dev: false
/passport@0.6.0:
resolution: {integrity: sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==}
engines: {node: '>= 0.4.0'}
dependencies:
passport-strategy: 1.0.0
pause: 0.0.1
utils-merge: 1.0.1
dev: false
/password-prompt@1.1.3:
resolution: {integrity: sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==}
dependencies:
@ -21129,10 +21054,6 @@ packages:
through: 2.3.8
dev: true
/pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
dev: false
/pdf-parse@1.1.1:
resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==}
engines: {node: '>=6.8.1'}