mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
refactor(core): Remove all legacy auth middleware code (no-changelog) (#8755)
This commit is contained in:
parent
2e84684f04
commit
56c8791aff
|
@ -72,10 +72,8 @@
|
||||||
"@types/json-diff": "^1.0.0",
|
"@types/json-diff": "^1.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.1",
|
"@types/jsonwebtoken": "^9.0.1",
|
||||||
"@types/lodash": "^4.14.195",
|
"@types/lodash": "^4.14.195",
|
||||||
"@types/passport-jwt": "^3.0.6",
|
|
||||||
"@types/psl": "^1.1.0",
|
"@types/psl": "^1.1.0",
|
||||||
"@types/replacestream": "^4.0.1",
|
"@types/replacestream": "^4.0.1",
|
||||||
"@types/send": "^0.17.1",
|
|
||||||
"@types/shelljs": "^0.8.11",
|
"@types/shelljs": "^0.8.11",
|
||||||
"@types/sshpk": "^1.17.1",
|
"@types/sshpk": "^1.17.1",
|
||||||
"@types/superagent": "4.1.13",
|
"@types/superagent": "4.1.13",
|
||||||
|
@ -153,9 +151,6 @@
|
||||||
"otpauth": "9.1.1",
|
"otpauth": "9.1.1",
|
||||||
"p-cancelable": "2.1.1",
|
"p-cancelable": "2.1.1",
|
||||||
"p-lazy": "3.1.0",
|
"p-lazy": "3.1.0",
|
||||||
"passport": "0.6.0",
|
|
||||||
"passport-cookie": "1.0.9",
|
|
||||||
"passport-jwt": "4.0.1",
|
|
||||||
"pg": "8.11.3",
|
"pg": "8.11.3",
|
||||||
"picocolors": "1.0.0",
|
"picocolors": "1.0.0",
|
||||||
"pkce-challenge": "3.0.0",
|
"pkce-challenge": "3.0.0",
|
||||||
|
|
|
@ -613,18 +613,6 @@ export interface ILicensePostResponse extends ILicenseReadResponse {
|
||||||
managementToken: string;
|
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 {
|
export interface PublicUser {
|
||||||
id: string;
|
id: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
|
@ -62,7 +62,6 @@ import { EventBusController } from '@/eventbus/eventBus.controller';
|
||||||
import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee';
|
import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee';
|
||||||
import { LicenseController } from '@/license/license.controller';
|
import { LicenseController } from '@/license/license.controller';
|
||||||
import { setupPushServer, setupPushHandler } from '@/push';
|
import { setupPushServer, setupPushHandler } from '@/push';
|
||||||
import { setupAuthMiddlewares } from './middlewares';
|
|
||||||
import { isLdapEnabled } from './Ldap/helpers';
|
import { isLdapEnabled } from './Ldap/helpers';
|
||||||
import { AbstractServer } from './AbstractServer';
|
import { AbstractServer } from './AbstractServer';
|
||||||
import { PostHogClient } from './posthog';
|
import { PostHogClient } from './posthog';
|
||||||
|
@ -129,9 +128,8 @@ export class Server extends AbstractServer {
|
||||||
Container.get(CollaborationService);
|
Container.get(CollaborationService);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async registerControllers(ignoredEndpoints: Readonly<string[]>) {
|
private async registerControllers() {
|
||||||
const { app } = this;
|
const { app } = this;
|
||||||
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
|
|
||||||
|
|
||||||
const controllers: Array<Class<object>> = [
|
const controllers: Array<Class<object>> = [
|
||||||
EventBusController,
|
EventBusController,
|
||||||
|
@ -278,7 +276,7 @@ export class Server extends AbstractServer {
|
||||||
|
|
||||||
await handleMfaDisable();
|
await handleMfaDisable();
|
||||||
|
|
||||||
await this.registerControllers(ignoredEndpoints);
|
await this.registerControllers();
|
||||||
|
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// SAML
|
// SAML
|
||||||
|
|
224
packages/cli/src/auth/auth.service.ts
Normal file
224
packages/cli/src/auth/auth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 { Container } from 'typedi';
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
import type { Response } from 'express';
|
||||||
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';
|
|
||||||
|
|
||||||
export function issueJWT(user: User): JwtToken {
|
import type { User } from '@db/entities/User';
|
||||||
const { id, email, password } = user;
|
import { AuthService } from './auth.service';
|
||||||
const expiresInHours = config.getEnv('userManagement.jwtSessionDurationHours');
|
|
||||||
const expiresInSeconds = expiresInHours * Time.hours.toSeconds;
|
|
||||||
|
|
||||||
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
|
// This method is still used by cloud hooks.
|
||||||
|
// DO NOT DELETE until the hooks have been updated
|
||||||
const payload: JwtPayload = {
|
/** @deprecated Use `AuthService` instead */
|
||||||
id,
|
export function issueCookie(res: Response, user: User) {
|
||||||
email,
|
return Container.get(AuthService).issueCookie(res, user);
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,6 +131,7 @@ export const Time = {
|
||||||
},
|
},
|
||||||
days: {
|
days: {
|
||||||
toSeconds: 24 * 60 * 60,
|
toSeconds: 24 * 60 * 60,
|
||||||
|
toMilliseconds: 24 * 60 * 60 * 1000,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { Authorized, Get, Post, RestController } from '@/decorators';
|
import { Authorized, Get, Post, RestController } from '@/decorators';
|
||||||
import { issueCookie, resolveJwt } from '@/auth/jwt';
|
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||||
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { LoginRequest, UserRequest } from '@/requests';
|
import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests';
|
||||||
import type { PublicUser } from '@/Interfaces';
|
import type { PublicUser } from '@/Interfaces';
|
||||||
import config from '@/config';
|
|
||||||
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
||||||
import { PostHogClient } from '@/posthog';
|
import { PostHogClient } from '@/posthog';
|
||||||
import {
|
import {
|
||||||
|
@ -20,7 +20,6 @@ import { UserService } from '@/services/user.service';
|
||||||
import { MfaService } from '@/Mfa/mfa.service';
|
import { MfaService } from '@/Mfa/mfa.service';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
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 { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||||
import { ApplicationError } from 'n8n-workflow';
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
@ -31,6 +30,7 @@ export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly internalHooks: InternalHooks,
|
private readonly internalHooks: InternalHooks,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly license: License,
|
private readonly license: License,
|
||||||
|
@ -96,7 +96,7 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await issueCookie(res, user);
|
this.authService.issueCookie(res, user);
|
||||||
void this.internalHooks.onUserLoginSuccess({
|
void this.internalHooks.onUserLoginSuccess({
|
||||||
user,
|
user,
|
||||||
authenticationMethod: usedAuthenticationMethod,
|
authenticationMethod: usedAuthenticationMethod,
|
||||||
|
@ -112,45 +112,14 @@ export class AuthController {
|
||||||
throw new AuthError('Wrong username or password. Do you have caps lock on?');
|
throw new AuthError('Wrong username or password. Do you have caps lock on?');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Check if the user is already logged in */
|
||||||
* Manually check the `n8n-auth` cookie.
|
@Authorized()
|
||||||
*/
|
|
||||||
@Get('/login')
|
@Get('/login')
|
||||||
async currentUser(req: Request, res: Response): Promise<PublicUser> {
|
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
|
||||||
// Manually check the existing cookie.
|
return await this.userService.toPublic(req.user, {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
posthog: this.postHog,
|
||||||
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
|
withScopes: true,
|
||||||
|
});
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -228,8 +197,8 @@ export class AuthController {
|
||||||
*/
|
*/
|
||||||
@Authorized()
|
@Authorized()
|
||||||
@Post('/logout')
|
@Post('/logout')
|
||||||
logout(req: Request, res: Response) {
|
logout(_: Request, res: Response) {
|
||||||
res.clearCookie(AUTH_COOKIE_NAME);
|
this.authService.clearCookie(res);
|
||||||
return { loggedOut: true };
|
return { loggedOut: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { Authorized, NoAuthRequired, Post, RequireGlobalScope, RestController } from '@/decorators';
|
import { Authorized, NoAuthRequired, Post, RequireGlobalScope, RestController } from '@/decorators';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
|
||||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||||
import { UserRequest } from '@/requests';
|
import { UserRequest } from '@/requests';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
@ -26,6 +26,7 @@ export class InvitationController {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly internalHooks: InternalHooks,
|
private readonly internalHooks: InternalHooks,
|
||||||
private readonly externalHooks: ExternalHooks,
|
private readonly externalHooks: ExternalHooks,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly license: License,
|
private readonly license: License,
|
||||||
private readonly passwordUtility: PasswordUtility,
|
private readonly passwordUtility: PasswordUtility,
|
||||||
|
@ -165,7 +166,7 @@ export class InvitationController {
|
||||||
|
|
||||||
const updatedUser = await this.userRepository.save(invitee, { transaction: false });
|
const updatedUser = await this.userRepository.save(invitee, { transaction: false });
|
||||||
|
|
||||||
await issueCookie(res, updatedUser);
|
this.authService.issueCookie(res, updatedUser);
|
||||||
|
|
||||||
void this.internalHooks.onUserSignup(updatedUser, {
|
void this.internalHooks.onUserSignup(updatedUser, {
|
||||||
user_type: 'email',
|
user_type: 'email',
|
||||||
|
|
|
@ -2,10 +2,11 @@ import validator from 'validator';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators';
|
import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators';
|
||||||
import { PasswordUtility } from '@/services/password.utility';
|
import { PasswordUtility } from '@/services/password.utility';
|
||||||
import { validateEntity } from '@/GenericHelpers';
|
import { validateEntity } from '@/GenericHelpers';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import {
|
import {
|
||||||
AuthenticatedRequest,
|
AuthenticatedRequest,
|
||||||
|
@ -14,7 +15,7 @@ import {
|
||||||
UserUpdatePayload,
|
UserUpdatePayload,
|
||||||
} from '@/requests';
|
} from '@/requests';
|
||||||
import type { PublicUser } from '@/Interfaces';
|
import type { PublicUser } from '@/Interfaces';
|
||||||
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
|
import { isSamlLicensedAndEnabled } from '@/sso/saml/samlHelpers';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import { ExternalHooks } from '@/ExternalHooks';
|
import { ExternalHooks } from '@/ExternalHooks';
|
||||||
|
@ -29,6 +30,7 @@ export class MeController {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly externalHooks: ExternalHooks,
|
private readonly externalHooks: ExternalHooks,
|
||||||
private readonly internalHooks: InternalHooks,
|
private readonly internalHooks: InternalHooks,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly passwordUtility: PasswordUtility,
|
private readonly passwordUtility: PasswordUtility,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
|
@ -84,7 +86,7 @@ export class MeController {
|
||||||
|
|
||||||
this.logger.info('User updated successfully', { userId });
|
this.logger.info('User updated successfully', { userId });
|
||||||
|
|
||||||
await issueCookie(res, user);
|
this.authService.issueCookie(res, user);
|
||||||
|
|
||||||
const updatedKeys = Object.keys(payload);
|
const updatedKeys = Object.keys(payload);
|
||||||
void this.internalHooks.onUserUpdate({
|
void this.internalHooks.onUserUpdate({
|
||||||
|
@ -137,7 +139,7 @@ export class MeController {
|
||||||
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
||||||
this.logger.info('Password updated successfully', { userId: user.id });
|
this.logger.info('Password updated successfully', { userId: user.id });
|
||||||
|
|
||||||
await issueCookie(res, updatedUser);
|
this.authService.issueCookie(res, updatedUser);
|
||||||
|
|
||||||
void this.internalHooks.onUserUpdate({
|
void this.internalHooks.onUserUpdate({
|
||||||
user: updatedUser,
|
user: updatedUser,
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { validateEntity } from '@/GenericHelpers';
|
import { validateEntity } from '@/GenericHelpers';
|
||||||
import { Authorized, Post, RestController } from '@/decorators';
|
import { Authorized, Post, RestController } from '@/decorators';
|
||||||
import { PasswordUtility } from '@/services/password.utility';
|
import { PasswordUtility } from '@/services/password.utility';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
|
||||||
import { OwnerRequest } from '@/requests';
|
import { OwnerRequest } from '@/requests';
|
||||||
import { SettingsRepository } from '@db/repositories/settings.repository';
|
import { SettingsRepository } from '@db/repositories/settings.repository';
|
||||||
|
import { UserRepository } from '@db/repositories/user.repository';
|
||||||
import { PostHogClient } from '@/posthog';
|
import { PostHogClient } from '@/posthog';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
|
||||||
|
|
||||||
@Authorized('global:owner')
|
@Authorized('global:owner')
|
||||||
@RestController('/owner')
|
@RestController('/owner')
|
||||||
|
@ -22,6 +22,7 @@ export class OwnerController {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly internalHooks: InternalHooks,
|
private readonly internalHooks: InternalHooks,
|
||||||
private readonly settingsRepository: SettingsRepository,
|
private readonly settingsRepository: SettingsRepository,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly passwordUtility: PasswordUtility,
|
private readonly passwordUtility: PasswordUtility,
|
||||||
private readonly postHog: PostHogClient,
|
private readonly postHog: PostHogClient,
|
||||||
|
@ -89,7 +90,7 @@ export class OwnerController {
|
||||||
|
|
||||||
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId });
|
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId });
|
||||||
|
|
||||||
await issueCookie(res, owner);
|
this.authService.issueCookie(res, owner);
|
||||||
|
|
||||||
void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });
|
void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { Response } from 'express';
|
||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { Get, Post, RestController } from '@/decorators';
|
import { Get, Post, RestController } from '@/decorators';
|
||||||
import { PasswordUtility } from '@/services/password.utility';
|
import { PasswordUtility } from '@/services/password.utility';
|
||||||
import { UserManagementMailer } from '@/UserManagement/email';
|
import { UserManagementMailer } from '@/UserManagement/email';
|
||||||
import { PasswordResetRequest } from '@/requests';
|
import { PasswordResetRequest } from '@/requests';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
|
||||||
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
@ -36,6 +36,7 @@ export class PasswordResetController {
|
||||||
private readonly externalHooks: ExternalHooks,
|
private readonly externalHooks: ExternalHooks,
|
||||||
private readonly internalHooks: InternalHooks,
|
private readonly internalHooks: InternalHooks,
|
||||||
private readonly mailer: UserManagementMailer,
|
private readonly mailer: UserManagementMailer,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
|
@ -114,7 +115,7 @@ export class PasswordResetController {
|
||||||
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
|
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.userService.generatePasswordResetUrl(user);
|
const url = this.authService.generatePasswordResetUrl(user);
|
||||||
|
|
||||||
const { id, firstName, lastName } = user;
|
const { id, firstName, lastName } = user;
|
||||||
try {
|
try {
|
||||||
|
@ -163,7 +164,7 @@ export class PasswordResetController {
|
||||||
throw new BadRequestError('');
|
throw new BadRequestError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userService.resolvePasswordResetToken(token);
|
const user = await this.authService.resolvePasswordResetToken(token);
|
||||||
if (!user) throw new NotFoundError('');
|
if (!user) throw new NotFoundError('');
|
||||||
|
|
||||||
if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
|
if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
|
||||||
|
@ -197,7 +198,7 @@ export class PasswordResetController {
|
||||||
|
|
||||||
const validPassword = this.passwordUtility.validate(password);
|
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) throw new NotFoundError('');
|
||||||
|
|
||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
|
@ -216,7 +217,7 @@ export class PasswordResetController {
|
||||||
|
|
||||||
this.logger.info('User password updated successfully', { userId: user.id });
|
this.logger.info('User password updated successfully', { userId: user.id });
|
||||||
|
|
||||||
await issueCookie(res, user);
|
this.authService.issueCookie(res, user);
|
||||||
|
|
||||||
void this.internalHooks.onUserUpdate({
|
void this.internalHooks.onUserUpdate({
|
||||||
user,
|
user,
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { User } from '@db/entities/User';
|
import { User } from '@db/entities/User';
|
||||||
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
||||||
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
||||||
|
@ -22,7 +25,6 @@ import { AuthIdentity } from '@db/entities/AuthIdentity';
|
||||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||||
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
import { UserRepository } from '@db/repositories/user.repository';
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { listQueryMiddleware } from '@/middlewares';
|
import { listQueryMiddleware } from '@/middlewares';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
|
@ -44,6 +46,7 @@ export class UsersController {
|
||||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly activeWorkflowRunner: ActiveWorkflowRunner,
|
private readonly activeWorkflowRunner: ActiveWorkflowRunner,
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -116,7 +119,7 @@ export class UsersController {
|
||||||
throw new NotFoundError('User not found');
|
throw new NotFoundError('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = this.userService.generatePasswordResetUrl(user);
|
const link = this.authService.generatePasswordResetUrl(user);
|
||||||
return { link };
|
return { link };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { Application, Request, Response, RequestHandler } 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 type { Class } from 'n8n-core';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||||
|
import { License } from '@/License';
|
||||||
import type { AuthenticatedRequest } from '@/requests';
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
|
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
|
||||||
import {
|
import {
|
||||||
|
@ -15,7 +20,6 @@ import {
|
||||||
CONTROLLER_ROUTES,
|
CONTROLLER_ROUTES,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import type {
|
import type {
|
||||||
AuthRole,
|
|
||||||
AuthRoleMetadata,
|
AuthRoleMetadata,
|
||||||
Controller,
|
Controller,
|
||||||
LicenseMetadata,
|
LicenseMetadata,
|
||||||
|
@ -23,23 +27,6 @@ import type {
|
||||||
RouteMetadata,
|
RouteMetadata,
|
||||||
ScopeMetadata,
|
ScopeMetadata,
|
||||||
} from './types';
|
} 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 =
|
export const createLicenseMiddleware =
|
||||||
(features: BooleanLicenseFeature[]): RequestHandler =>
|
(features: BooleanLicenseFeature[]): RequestHandler =>
|
||||||
|
@ -77,11 +64,6 @@ export const createGlobalScopeMiddleware =
|
||||||
return next();
|
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>) => {
|
export const registerController = (app: Application, controllerClass: Class<object>) => {
|
||||||
const controller = Container.get(controllerClass as Class<Controller>);
|
const controller = Container.get(controllerClass as Class<Controller>);
|
||||||
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
|
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[]
|
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
|
||||||
).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler);
|
).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler);
|
||||||
|
|
||||||
|
const authService = Container.get(AuthService);
|
||||||
|
|
||||||
routes.forEach(
|
routes.forEach(
|
||||||
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => {
|
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => {
|
||||||
const authRole = authRoles?.[handlerName] ?? authRoles?.['*'];
|
const authRole = authRoles?.[handlerName] ?? authRoles?.['*'];
|
||||||
|
@ -123,14 +107,13 @@ export const registerController = (app: Application, controllerClass: Class<obje
|
||||||
await controller[handlerName](req, res);
|
await controller[handlerName](req, res);
|
||||||
router[method](
|
router[method](
|
||||||
path,
|
path,
|
||||||
...(authRole ? [createAuthMiddleware(authRole)] : []),
|
...(authRole ? [authService.createAuthMiddleware(authRole)] : []),
|
||||||
...(features ? [createLicenseMiddleware(features)] : []),
|
...(features ? [createLicenseMiddleware(features)] : []),
|
||||||
...(scopes ? [createGlobalScopeMiddleware(scopes)] : []),
|
...(scopes ? [createGlobalScopeMiddleware(scopes)] : []),
|
||||||
...controllerMiddlewares,
|
...controllerMiddlewares,
|
||||||
...routeMiddlewares,
|
...routeMiddlewares,
|
||||||
usesTemplates ? handler : send(handler),
|
usesTemplates ? handler : send(handler),
|
||||||
);
|
);
|
||||||
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -1,4 +1,3 @@
|
||||||
export * from './auth';
|
|
||||||
export * from './bodyParser';
|
export * from './bodyParser';
|
||||||
export * from './cors';
|
export * from './cors';
|
||||||
export * from './listQuery';
|
export * from './listQuery';
|
||||||
|
|
|
@ -7,14 +7,13 @@ import { Server as WSServer } from 'ws';
|
||||||
import { parse as parseUrl } from 'url';
|
import { parse as parseUrl } from 'url';
|
||||||
import { Container, Service } from 'typedi';
|
import { Container, Service } from 'typedi';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { resolveJwt } from '@/auth/jwt';
|
|
||||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
|
||||||
import { SSEPush } from './sse.push';
|
import { SSEPush } from './sse.push';
|
||||||
import { WebSocketPush } from './websocket.push';
|
import { WebSocketPush } from './websocket.push';
|
||||||
import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
||||||
import type { IPushDataType } from '@/Interfaces';
|
import type { IPushDataType } from '@/Interfaces';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { OnShutdown } from '@/decorators/OnShutdown';
|
import { OnShutdown } from '@/decorators/OnShutdown';
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
|
|
||||||
const useWebSockets = config.getEnv('push.backend') === 'websocket';
|
const useWebSockets = config.getEnv('push.backend') === 'websocket';
|
||||||
|
|
||||||
|
@ -120,27 +119,15 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => {
|
||||||
}
|
}
|
||||||
return;
|
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();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
const push = Container.get(Push);
|
const push = Container.get(Push);
|
||||||
|
const authService = Container.get(AuthService);
|
||||||
app.use(
|
app.use(
|
||||||
endpoint,
|
endpoint,
|
||||||
|
authService.createAuthMiddleware('any'),
|
||||||
pushValidationMiddleware,
|
pushValidationMiddleware,
|
||||||
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) => push.handleRequest(req, res),
|
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) => push.handleRequest(req, res),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import type { User } from '@db/entities/User';
|
import type { Response } from 'express';
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import type { User } from '@db/entities/User';
|
||||||
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
|
|
||||||
// TODO: move all push related types here
|
// 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 SSEPushRequest = PushRequest & { ws: undefined; userId: User['id'] };
|
||||||
export type WebSocketPushRequest = PushRequest & { ws: WebSocket; userId: User['id'] };
|
export type WebSocketPushRequest = PushRequest & { ws: WebSocket; userId: User['id'] };
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ParsedQs } from 'qs';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import type {
|
import type {
|
||||||
BannerName,
|
BannerName,
|
||||||
|
@ -20,6 +21,17 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||||
import type { WorkflowHistory } from '@db/entities/WorkflowHistory';
|
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'> {
|
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -62,8 +74,12 @@ export type AuthenticatedRequest<
|
||||||
ResponseBody = {},
|
ResponseBody = {},
|
||||||
RequestBody = {},
|
RequestBody = {},
|
||||||
RequestQuery = {},
|
RequestQuery = {},
|
||||||
> = Omit<express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user'> & {
|
> = Omit<
|
||||||
|
express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>,
|
||||||
|
'user' | 'cookies'
|
||||||
|
> & {
|
||||||
user: User;
|
user: User;
|
||||||
|
cookies: Record<string, string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -1,17 +1,15 @@
|
||||||
import { Container, Service } from 'typedi';
|
import { Container, Service } from 'typedi';
|
||||||
import { type AssignableRole, User } from '@db/entities/User';
|
|
||||||
import type { IUserSettings } from 'n8n-workflow';
|
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 { UserRepository } from '@db/repositories/user.repository';
|
||||||
import type { PublicUser } from '@/Interfaces';
|
import type { PublicUser } from '@/Interfaces';
|
||||||
import type { PostHogClient } from '@/posthog';
|
import type { PostHogClient } from '@/posthog';
|
||||||
import { type JwtPayload, JwtService } from './jwt.service';
|
|
||||||
import { TokenExpiredError } from 'jsonwebtoken';
|
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import { createPasswordSha } from '@/auth/jwt';
|
|
||||||
import { UserManagementMailer } from '@/UserManagement/email';
|
import { UserManagementMailer } from '@/UserManagement/email';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
|
||||||
import type { UserRequest } from '@/requests';
|
import type { UserRequest } from '@/requests';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
|
|
||||||
|
@ -20,7 +18,6 @@ export class UserService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly jwtService: JwtService,
|
|
||||||
private readonly mailer: UserManagementMailer,
|
private readonly mailer: UserManagementMailer,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
) {}
|
) {}
|
||||||
|
@ -39,57 +36,6 @@ export class UserService {
|
||||||
return await this.userRepository.update(userId, { settings: { ...settings, ...newSettings } });
|
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(
|
async toPublic(
|
||||||
user: User,
|
user: User,
|
||||||
options?: {
|
options?: {
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import type { PostBindingContext } from 'samlify/types/src/entity';
|
||||||
|
import url from 'url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Authorized,
|
Authorized,
|
||||||
Get,
|
Get,
|
||||||
|
@ -7,20 +11,16 @@ import {
|
||||||
RestController,
|
RestController,
|
||||||
RequireGlobalScope,
|
RequireGlobalScope,
|
||||||
} from '@/decorators';
|
} from '@/decorators';
|
||||||
import { SamlUrls } from '../constants';
|
|
||||||
import {
|
import { AuthService } from '@/auth/auth.service';
|
||||||
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 { AuthenticatedRequest } from '@/requests';
|
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 {
|
import {
|
||||||
getServiceProviderConfigTestReturnUrl,
|
getServiceProviderConfigTestReturnUrl,
|
||||||
getServiceProviderEntityId,
|
getServiceProviderEntityId,
|
||||||
|
@ -28,17 +28,21 @@ import {
|
||||||
} from '../serviceProvider.ee';
|
} from '../serviceProvider.ee';
|
||||||
import { getSamlConnectionTestSuccessView } from '../views/samlConnectionTestSuccess';
|
import { getSamlConnectionTestSuccessView } from '../views/samlConnectionTestSuccess';
|
||||||
import { getSamlConnectionTestFailedView } from '../views/samlConnectionTestFailed';
|
import { getSamlConnectionTestFailedView } from '../views/samlConnectionTestFailed';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../samlHelpers';
|
||||||
import url from 'url';
|
import type { SamlLoginBinding } from '../types';
|
||||||
import querystring from 'querystring';
|
import {
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
samlLicensedAndEnabledMiddleware,
|
||||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
samlLicensedMiddleware,
|
||||||
import { UrlService } from '@/services/url.service';
|
} from '../middleware/samlEnabledMiddleware';
|
||||||
|
import { SamlService } from '../saml.service.ee';
|
||||||
|
import { SamlConfiguration } from '../types/requests';
|
||||||
|
import { getInitSSOFormView } from '../views/initSsoPost';
|
||||||
|
|
||||||
@Authorized()
|
@Authorized()
|
||||||
@RestController('/sso/saml')
|
@RestController('/sso/saml')
|
||||||
export class SamlController {
|
export class SamlController {
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
private readonly samlService: SamlService,
|
private readonly samlService: SamlService,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
private readonly internalHooks: InternalHooks,
|
private readonly internalHooks: InternalHooks,
|
||||||
|
@ -46,7 +50,7 @@ export class SamlController {
|
||||||
|
|
||||||
@NoAuthRequired()
|
@NoAuthRequired()
|
||||||
@Get(SamlUrls.metadata)
|
@Get(SamlUrls.metadata)
|
||||||
async getServiceProviderMetadata(req: express.Request, res: express.Response) {
|
async getServiceProviderMetadata(_: express.Request, res: express.Response) {
|
||||||
return res
|
return res
|
||||||
.header('Content-Type', 'text/xml')
|
.header('Content-Type', 'text/xml')
|
||||||
.send(this.samlService.getServiceProviderInstance().getMetadata());
|
.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
|
// Only sign in user if SAML is enabled, otherwise treat as test connection
|
||||||
if (isSamlLicensedAndEnabled()) {
|
if (isSamlLicensedAndEnabled()) {
|
||||||
await issueCookie(res, loginResult.authenticatedUser);
|
this.authService.issueCookie(res, loginResult.authenticatedUser);
|
||||||
if (loginResult.onboardingRequired) {
|
if (loginResult.onboardingRequired) {
|
||||||
return res.redirect(this.urlService.getInstanceBaseUrl() + SamlUrls.samlOnboarding);
|
return res.redirect(this.urlService.getInstanceBaseUrl() + SamlUrls.samlOnboarding);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -157,18 +157,6 @@ describe('GET /login', () => {
|
||||||
expect(authToken).toBeUndefined();
|
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 () => {
|
test('should return 401 Unauthorized if invalid cookie', async () => {
|
||||||
testServer.authlessAgent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`);
|
testServer.authlessAgent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`);
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@ describe('Auth Middleware', () => {
|
||||||
['PATCH', '/me/password'],
|
['PATCH', '/me/password'],
|
||||||
['POST', '/me/survey'],
|
['POST', '/me/survey'],
|
||||||
['POST', '/owner/setup'],
|
['POST', '/owner/setup'],
|
||||||
['GET', '/non-existent'],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Routes requiring a valid `n8n-auth` cookie for an owner. */
|
/** Routes requiring a valid `n8n-auth` cookie for an owner. */
|
||||||
|
|
|
@ -453,7 +453,7 @@ describe('POST /ldap/sync', () => {
|
||||||
await authOwnerAgent.post('/ldap/sync').send({ type: 'live' });
|
await authOwnerAgent.post('/ldap/sync').send({ type: 'live' });
|
||||||
|
|
||||||
const response = await testServer.authAgentFor(member).get('/login');
|
const response = await testServer.authAgentFor(member).get('/login');
|
||||||
expect(response.body.code).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -300,10 +300,6 @@ describe('Member', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Owner', () => {
|
describe('Owner', () => {
|
||||||
beforeEach(async () => {
|
|
||||||
await utils.setInstanceOwnerSetUp(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PATCH /me should succeed with valid inputs', async () => {
|
test('PATCH /me should succeed with valid inputs', async () => {
|
||||||
const owner = await createUser({ role: 'global:owner' });
|
const owner = await createUser({ role: 'global:owner' });
|
||||||
const authOwnerAgent = testServer.authAgentFor(owner);
|
const authOwnerAgent = testServer.authAgentFor(owner);
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
|
import { UserRepository } from '@db/repositories/user.repository';
|
||||||
import { randomPassword } from '@/Ldap/helpers';
|
import { randomPassword } from '@/Ldap/helpers';
|
||||||
import { TOTPService } from '@/Mfa/totp.service';
|
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 testDb from '../shared/testDb';
|
||||||
import * as utils from '../shared/utils';
|
import * as utils from '../shared/utils';
|
||||||
|
import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random';
|
||||||
import { createUser, createUserWithMfaEnabled } from '../shared/db/users';
|
import { createUser, createUserWithMfaEnabled } from '../shared/db/users';
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
|
||||||
|
|
||||||
jest.mock('@/telemetry');
|
jest.mock('@/telemetry');
|
||||||
|
|
||||||
|
@ -241,7 +243,7 @@ describe('Change password with MFA enabled', () => {
|
||||||
|
|
||||||
config.set('userManagement.jwtSecret', randomString(5, 10));
|
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);
|
const mfaToken = new TOTPService().generateTOTP(rawSecret);
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,13 @@ import { compare } from 'bcryptjs';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
import { ExternalHooks } from '@/ExternalHooks';
|
import { ExternalHooks } from '@/ExternalHooks';
|
||||||
import { JwtService } from '@/services/jwt.service';
|
import { JwtService } from '@/services/jwt.service';
|
||||||
import { UserService } from '@/services/user.service';
|
|
||||||
import { UserManagementMailer } from '@/UserManagement/email';
|
import { UserManagementMailer } from '@/UserManagement/email';
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
import { UserRepository } from '@db/repositories/user.repository';
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ const externalHooks = mockInstance(ExternalHooks);
|
||||||
const mailer = mockInstance(UserManagementMailer, { isEmailSetUp: true });
|
const mailer = mockInstance(UserManagementMailer, { isEmailSetUp: true });
|
||||||
const testServer = setupTestServer({ endpointGroups: ['passwordReset'] });
|
const testServer = setupTestServer({ endpointGroups: ['passwordReset'] });
|
||||||
const jwtService = Container.get(JwtService);
|
const jwtService = Container.get(JwtService);
|
||||||
let userService: UserService;
|
let authService: AuthService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testDb.truncate(['User']);
|
await testDb.truncate(['User']);
|
||||||
|
@ -43,7 +43,7 @@ beforeEach(async () => {
|
||||||
member = await createUser({ role: 'global:member' });
|
member = await createUser({ role: 'global:member' });
|
||||||
externalHooks.run.mockReset();
|
externalHooks.run.mockReset();
|
||||||
jest.replaceProperty(mailer, 'isEmailSetUp', true);
|
jest.replaceProperty(mailer, 'isEmailSetUp', true);
|
||||||
userService = Container.get(UserService);
|
authService = Container.get(AuthService);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /forgot-password', () => {
|
describe('POST /forgot-password', () => {
|
||||||
|
@ -126,7 +126,7 @@ describe('POST /forgot-password', () => {
|
||||||
|
|
||||||
describe('GET /resolve-password-token', () => {
|
describe('GET /resolve-password-token', () => {
|
||||||
test('should succeed with valid inputs', async () => {
|
test('should succeed with valid inputs', async () => {
|
||||||
const resetPasswordToken = userService.generatePasswordResetToken(owner);
|
const resetPasswordToken = authService.generatePasswordResetToken(owner);
|
||||||
|
|
||||||
const response = await testServer.authlessAgent
|
const response = await testServer.authlessAgent
|
||||||
.get('/resolve-password-token')
|
.get('/resolve-password-token')
|
||||||
|
@ -158,7 +158,7 @@ describe('GET /resolve-password-token', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail if token is expired', async () => {
|
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
|
const response = await testServer.authlessAgent
|
||||||
.get('/resolve-password-token')
|
.get('/resolve-password-token')
|
||||||
|
@ -169,7 +169,7 @@ describe('GET /resolve-password-token', () => {
|
||||||
|
|
||||||
test('should fail after password has changed', async () => {
|
test('should fail after password has changed', async () => {
|
||||||
const updatedUser = mock<User>({ ...owner, password: 'another-password' });
|
const updatedUser = mock<User>({ ...owner, password: 'another-password' });
|
||||||
const resetPasswordToken = userService.generatePasswordResetToken(updatedUser);
|
const resetPasswordToken = authService.generatePasswordResetToken(updatedUser);
|
||||||
|
|
||||||
const response = await testServer.authlessAgent
|
const response = await testServer.authlessAgent
|
||||||
.get('/resolve-password-token')
|
.get('/resolve-password-token')
|
||||||
|
@ -183,7 +183,7 @@ describe('POST /change-password', () => {
|
||||||
const passwordToStore = randomValidPassword();
|
const passwordToStore = randomValidPassword();
|
||||||
|
|
||||||
test('should succeed with valid inputs', async () => {
|
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({
|
const response = await testServer.authlessAgent.post('/change-password').send({
|
||||||
token: resetPasswordToken,
|
token: resetPasswordToken,
|
||||||
userId: owner.id,
|
userId: owner.id,
|
||||||
|
@ -213,7 +213,7 @@ describe('POST /change-password', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail with invalid inputs', async () => {
|
test('should fail with invalid inputs', async () => {
|
||||||
const resetPasswordToken = userService.generatePasswordResetToken(owner);
|
const resetPasswordToken = authService.generatePasswordResetToken(owner);
|
||||||
|
|
||||||
const invalidPayloads = [
|
const invalidPayloads = [
|
||||||
{ token: uuid() },
|
{ token: uuid() },
|
||||||
|
@ -247,7 +247,7 @@ describe('POST /change-password', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail when token has expired', async () => {
|
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({
|
const response = await testServer.authlessAgent.post('/change-password').send({
|
||||||
token: resetPasswordToken,
|
token: resetPasswordToken,
|
||||||
|
@ -263,7 +263,7 @@ describe('POST /change-password', () => {
|
||||||
test('owner should be able to reset its password when quota:users = 1', async () => {
|
test('owner should be able to reset its password when quota:users = 1', async () => {
|
||||||
jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1);
|
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({
|
const response = await testServer.authlessAgent.post('/change-password').send({
|
||||||
token: resetPasswordToken,
|
token: resetPasswordToken,
|
||||||
userId: owner.id,
|
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 () => {
|
test('member should not be able to reset its password when quota:users = 1', async () => {
|
||||||
jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1);
|
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({
|
const response = await testServer.authlessAgent.post('/change-password').send({
|
||||||
token: resetPasswordToken,
|
token: resetPasswordToken,
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
|
|
|
@ -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 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 = {
|
export const SUCCESS_RESPONSE_BODY = {
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
@ -35,7 +35,6 @@ type EndpointGroup =
|
||||||
| 'debug';
|
| 'debug';
|
||||||
|
|
||||||
export interface SetupProps {
|
export interface SetupProps {
|
||||||
applyAuth?: boolean;
|
|
||||||
endpointGroups?: EndpointGroup[];
|
endpointGroups?: EndpointGroup[];
|
||||||
enabledFeatures?: BooleanLicenseFeature[];
|
enabledFeatures?: BooleanLicenseFeature[];
|
||||||
quotas?: Partial<{ [K in NumericLicenseFeature]: number }>;
|
quotas?: Partial<{ [K in NumericLicenseFeature]: number }>;
|
||||||
|
|
|
@ -8,9 +8,8 @@ import { URL } from 'url';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { issueJWT } from '@/auth/jwt';
|
|
||||||
import { registerController } from '@/decorators';
|
import { registerController } from '@/decorators';
|
||||||
import { rawBodyReader, bodyParser, setupAuthMiddlewares } from '@/middlewares';
|
import { rawBodyReader, bodyParser } from '@/middlewares';
|
||||||
import { PostHogClient } from '@/posthog';
|
import { PostHogClient } from '@/posthog';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
@ -19,9 +18,10 @@ import { InternalHooks } from '@/InternalHooks';
|
||||||
|
|
||||||
import { mockInstance } from '../../../shared/mocking';
|
import { mockInstance } from '../../../shared/mocking';
|
||||||
import * as testDb from '../../shared/testDb';
|
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 type { SetupProps, TestServer } from '../types';
|
||||||
import { LicenseMocker } from '../license';
|
import { LicenseMocker } from '../license';
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin to prefix a path segment into a request URL pathname.
|
* 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);
|
const agent = request.agent(app);
|
||||||
void agent.use(prefix(REST_PATH_SEGMENT));
|
void agent.use(prefix(REST_PATH_SEGMENT));
|
||||||
if (options?.auth && options?.user) {
|
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}`);
|
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
|
@ -67,7 +67,6 @@ function publicApiAgent(
|
||||||
|
|
||||||
export const setupTestServer = ({
|
export const setupTestServer = ({
|
||||||
endpointGroups,
|
endpointGroups,
|
||||||
applyAuth = true,
|
|
||||||
enabledFeatures,
|
enabledFeatures,
|
||||||
quotas,
|
quotas,
|
||||||
}: SetupProps): TestServer => {
|
}: 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;
|
if (!endpointGroups) return;
|
||||||
|
|
||||||
app.use(bodyParser);
|
app.use(bodyParser);
|
||||||
|
|
||||||
|
const enablePublicAPI = endpointGroups?.includes('publicApi');
|
||||||
if (enablePublicAPI) {
|
if (enablePublicAPI) {
|
||||||
const { loadPublicApiVersions } = await import('@/PublicApi');
|
const { loadPublicApiVersions } = await import('@/PublicApi');
|
||||||
const { apiRouters } = await loadPublicApiVersions(PUBLIC_API_REST_PATH_SEGMENT);
|
const { apiRouters } = await loadPublicApiVersions(PUBLIC_API_REST_PATH_SEGMENT);
|
||||||
|
|
|
@ -28,6 +28,7 @@ describe('EnterpriseWorkflowService', () => {
|
||||||
Container.get(SharedWorkflowRepository),
|
Container.get(SharedWorkflowRepository),
|
||||||
Container.get(WorkflowRepository),
|
Container.get(WorkflowRepository),
|
||||||
Container.get(CredentialsRepository),
|
Container.get(CredentialsRepository),
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { NodeMailer } from '@/UserManagement/email/NodeMailer';
|
import { NodeMailer } from '@/UserManagement/email/NodeMailer';
|
||||||
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
|
import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
describe('UserManagementMailer', () => {
|
describe('UserManagementMailer', () => {
|
||||||
describe('expect NodeMailer.verifyConnection', () => {
|
describe('expect NodeMailer.verifyConnection', () => {
|
||||||
|
@ -20,7 +21,7 @@ describe('UserManagementMailer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('not be called when SMTP not set up', async () => {
|
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
|
// NodeMailer.verifyConnection gets called only explicitly
|
||||||
await expect(async () => await userManagementMailer.verifyConnection()).rejects.toThrow();
|
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.smtp.host', 'host');
|
||||||
config.set('userManagement.emails.mode', 'smtp');
|
config.set('userManagement.emails.mode', 'smtp');
|
||||||
|
|
||||||
const userManagementMailer = new UserManagementMailer();
|
const userManagementMailer = new UserManagementMailer(mock(), mock(), mock());
|
||||||
// NodeMailer.verifyConnection gets called only explicitly
|
// NodeMailer.verifyConnection gets called only explicitly
|
||||||
expect(async () => await userManagementMailer.verifyConnection()).not.toThrow();
|
expect(async () => await userManagementMailer.verifyConnection()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
292
packages/cli/test/unit/auth/auth.service.test.ts
Normal file
292
packages/cli/test/unit/auth/auth.service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,34 +1,37 @@
|
||||||
import type { CookieOptions, Response } from 'express';
|
import Container from 'typedi';
|
||||||
import { anyObject, captor, mock } from 'jest-mock-extended';
|
import type { Response } from 'express';
|
||||||
|
import { anyObject, mock } from 'jest-mock-extended';
|
||||||
import jwt from 'jsonwebtoken';
|
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 { User } from '@db/entities/User';
|
||||||
import type { SettingsRepository } from '@db/repositories/settings.repository';
|
import type { SettingsRepository } from '@db/repositories/settings.repository';
|
||||||
import config from '@/config';
|
import type { UserRepository } from '@db/repositories/user.repository';
|
||||||
import type { OwnerRequest } from '@/requests';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { OwnerController } from '@/controllers/owner.controller';
|
import type { InternalHooks } from '@/InternalHooks';
|
||||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
|
||||||
import { UserService } from '@/services/user.service';
|
|
||||||
import { License } from '@/License';
|
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 { mockInstance } from '../../shared/mocking';
|
||||||
import { badPasswords } from '../shared/testData';
|
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', () => {
|
describe('OwnerController', () => {
|
||||||
const configGetSpy = jest.spyOn(config, 'getEnv');
|
const configGetSpy = jest.spyOn(config, 'getEnv');
|
||||||
const internalHooks = mock<InternalHooks>();
|
const internalHooks = mock<InternalHooks>();
|
||||||
const userService = mockInstance(UserService);
|
const authService = mock<AuthService>();
|
||||||
const userRepository = mockInstance(UserRepository);
|
const userService = mock<UserService>();
|
||||||
|
const userRepository = mock<UserRepository>();
|
||||||
const settingsRepository = mock<SettingsRepository>();
|
const settingsRepository = mock<SettingsRepository>();
|
||||||
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
||||||
const controller = new OwnerController(
|
const controller = new OwnerController(
|
||||||
mock(),
|
mock(),
|
||||||
internalHooks,
|
internalHooks,
|
||||||
settingsRepository,
|
settingsRepository,
|
||||||
|
authService,
|
||||||
userService,
|
userService,
|
||||||
Container.get(PasswordUtility),
|
Container.get(PasswordUtility),
|
||||||
mock(),
|
mock(),
|
||||||
|
@ -96,11 +99,7 @@ describe('OwnerController', () => {
|
||||||
await controller.setupOwner(req, res);
|
await controller.setupOwner(req, res);
|
||||||
|
|
||||||
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
||||||
|
expect(authService.issueCookie).toHaveBeenCalledWith(res, user);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,22 +1,13 @@
|
||||||
import Container from 'typedi';
|
import { mock } from 'jest-mock-extended';
|
||||||
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 { v4 as uuid } from 'uuid';
|
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', () => {
|
describe('UserService', () => {
|
||||||
config.set('userManagement.jwtSecret', 'random-secret');
|
const urlService = new UrlService();
|
||||||
|
const userService = new UserService(mock(), mock(), mock(), urlService);
|
||||||
mockInstance(Logger);
|
|
||||||
mockInstance(InternalHooks);
|
|
||||||
|
|
||||||
const userRepository = mockInstance(UserRepository);
|
|
||||||
const userService = Container.get(UserService);
|
|
||||||
|
|
||||||
const commonMockUser = Object.assign(new User(), {
|
const commonMockUser = Object.assign(new User(), {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
@ -75,66 +66,4 @@ describe('UserService', () => {
|
||||||
expect(url.searchParams.get('inviteeId')).toBe(secondUser.id);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -548,15 +548,6 @@ importers:
|
||||||
p-lazy:
|
p-lazy:
|
||||||
specifier: 3.1.0
|
specifier: 3.1.0
|
||||||
version: 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:
|
pg:
|
||||||
specifier: 8.11.3
|
specifier: 8.11.3
|
||||||
version: 8.11.3
|
version: 8.11.3
|
||||||
|
@ -675,18 +666,12 @@ importers:
|
||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: ^4.14.195
|
specifier: ^4.14.195
|
||||||
version: 4.14.195
|
version: 4.14.195
|
||||||
'@types/passport-jwt':
|
|
||||||
specifier: ^3.0.6
|
|
||||||
version: 3.0.7
|
|
||||||
'@types/psl':
|
'@types/psl':
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
'@types/replacestream':
|
'@types/replacestream':
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
'@types/send':
|
|
||||||
specifier: ^0.17.1
|
|
||||||
version: 0.17.1
|
|
||||||
'@types/shelljs':
|
'@types/shelljs':
|
||||||
specifier: ^0.8.11
|
specifier: ^0.8.11
|
||||||
version: 0.8.11
|
version: 0.8.11
|
||||||
|
@ -10024,10 +10009,6 @@ packages:
|
||||||
resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==}
|
resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/mime@1.3.2:
|
|
||||||
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/mime@3.0.1:
|
/@types/mime@3.0.1:
|
||||||
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
||||||
|
|
||||||
|
@ -10082,27 +10063,6 @@ packages:
|
||||||
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
|
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
|
||||||
dev: true
|
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:
|
/@types/phoenix@1.6.4:
|
||||||
resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==}
|
resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -10183,13 +10143,6 @@ packages:
|
||||||
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
|
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
|
||||||
dev: true
|
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:
|
/@types/serve-static@1.15.0:
|
||||||
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
|
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -20984,34 +20937,6 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
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:
|
/password-prompt@1.1.3:
|
||||||
resolution: {integrity: sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==}
|
resolution: {integrity: sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -21129,10 +21054,6 @@ packages:
|
||||||
through: 2.3.8
|
through: 2.3.8
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/pause@0.0.1:
|
|
||||||
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/pdf-parse@1.1.1:
|
/pdf-parse@1.1.1:
|
||||||
resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==}
|
resolution: {integrity: sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==}
|
||||||
engines: {node: '>=6.8.1'}
|
engines: {node: '>=6.8.1'}
|
||||||
|
|
Loading…
Reference in a new issue