refactor(core): Enforce authorization by default on all routes (no-changelog) (#8762)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-02-28 17:02:18 +01:00 committed by GitHub
parent 2811f77798
commit db4a419c8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 126 additions and 299 deletions

View file

@ -1,6 +1,7 @@
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
export type Resource =
| 'auditLogs'
| 'banner'
| 'communityPackage'
| 'credential'
| 'externalSecretsProvider'
@ -27,6 +28,7 @@ export type ResourceScope<
export type WildcardScope = `${Resource}:*` | '*';
export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>;
export type BannerScope = ResourceScope<'banner', 'dismiss'>;
export type CommunityPackageScope = ResourceScope<
'communityPackage',
'install' | 'uninstall' | 'update' | 'list' | 'manage'
@ -56,6 +58,7 @@ export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share
export type Scope =
| AuditLogsScope
| BannerScope
| CommunityPackageScope
| CredentialScope
| ExternalSecretProviderScope

View file

@ -1,11 +1,10 @@
import { Authorized, Get, Post, RestController, GlobalScope } from '@/decorators';
import { Get, Post, RestController, GlobalScope } from '@/decorators';
import { ExternalSecretsRequest } from '@/requests';
import { Response } from 'express';
import { ExternalSecretsService } from './ExternalSecrets.service.ee';
import { ExternalSecretsProviderNotFoundError } from '@/errors/external-secrets-provider-not-found.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@Authorized()
@RestController('/external-secrets')
export class ExternalSecretsController {
constructor(private readonly secretsService: ExternalSecretsService) {}

View file

@ -1,5 +1,5 @@
import pick from 'lodash/pick';
import { Authorized, Get, Post, Put, RestController, GlobalScope } from '@/decorators';
import { Get, Post, Put, RestController, GlobalScope } from '@/decorators';
import { InternalHooks } from '@/InternalHooks';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -8,7 +8,6 @@ import { getLdapSynchronizations } from './helpers';
import { LdapConfiguration } from './types';
import { LdapService } from './ldap.service';
@Authorized()
@RestController('/ldap')
export class LdapController {
constructor(

View file

@ -1,5 +1,5 @@
import { Service } from 'typedi';
import type { Response } from 'express';
import type { NextFunction, Response } from 'express';
import { createHash } from 'crypto';
import { JsonWebTokenError, TokenExpiredError, type JwtPayload } from 'jsonwebtoken';
@ -7,12 +7,11 @@ 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 type { AuthenticatedRequest } from '@/requests';
import { JwtService } from '@/services/jwt.service';
import { UrlService } from '@/services/url.service';
@ -31,54 +30,33 @@ interface IssuedJWT extends AuthJwtPayload {
@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,
) {}
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.authMiddleware = this.authMiddleware.bind(this);
}
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;
}
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
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;
if (req.user) next();
else res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
clearCookie(res: Response) {

View file

@ -1,8 +1,7 @@
import { Authorized, Get, RestController } from '@/decorators';
import { Get, RestController } from '@/decorators';
import { ActiveWorkflowRequest } from '@/requests';
import { ActiveWorkflowsService } from '@/services/activeWorkflows.service';
@Authorized()
@RestController('/active-workflows')
export class ActiveWorkflowsController {
constructor(private readonly activeWorkflowsService: ActiveWorkflowsService) {}

View file

@ -1,7 +1,7 @@
import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import { Authorized, Get, Post, RestController } from '@/decorators';
import { Get, Post, RestController } from '@/decorators';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Request, Response } from 'express';
import type { User } from '@db/entities/User';
@ -38,10 +38,8 @@ export class AuthController {
private readonly postHog?: PostHogClient,
) {}
/**
* Log in a user.
*/
@Post('/login')
/** Log in a user */
@Post('/login', { skipAuth: true })
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
const { email, password, mfaToken, mfaRecoveryCode } = req.body;
if (!email) throw new ApplicationError('Email is required to log in');
@ -113,7 +111,6 @@ export class AuthController {
}
/** Check if the user is already logged in */
@Authorized()
@Get('/login')
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
return await this.userService.toPublic(req.user, {
@ -122,10 +119,8 @@ export class AuthController {
});
}
/**
* Validate invite token to enable invitee to set up their account.
*/
@Get('/resolve-signup-token')
/** Validate invite token to enable invitee to set up their account */
@Get('/resolve-signup-token', { skipAuth: true })
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
const { inviterId, inviteeId } = req.query;
const isWithinUsersLimit = this.license.isWithinUsersLimit();
@ -192,10 +187,7 @@ export class AuthController {
return { inviter: { firstName, lastName } };
}
/**
* Log out a user.
*/
@Authorized()
/** Log out a user */
@Post('/logout')
logout(_: Request, res: Response) {
this.authService.clearCookie(res);

View file

@ -5,16 +5,7 @@ import {
STARTER_TEMPLATE_NAME,
UNKNOWN_FAILURE_REASON,
} from '@/constants';
import {
Authorized,
Delete,
Get,
Middleware,
Patch,
Post,
RestController,
GlobalScope,
} from '@/decorators';
import { Delete, Get, Middleware, Patch, Post, RestController, GlobalScope } from '@/decorators';
import { NodeRequest } from '@/requests';
import type { InstalledPackages } from '@db/entities/InstalledPackages';
import type { CommunityPackages } from '@/Interfaces';
@ -41,7 +32,6 @@ export function isNpmError(error: unknown): error is { code: number; stdout: str
return typeof error === 'object' && error !== null && 'code' in error && 'stdout' in error;
}
@Authorized()
@RestController('/community-packages')
export class CommunityPackagesController {
constructor(

View file

@ -1,5 +1,5 @@
import express from 'express';
import { Authorized, Get, RestController } from '@/decorators';
import { Get, RestController } from '@/decorators';
import { AuthenticatedRequest } from '@/requests';
import { CtaService } from '@/services/cta.service';
@ -7,7 +7,6 @@ import { CtaService } from '@/services/cta.service';
* Controller for Call to Action (CTA) endpoints. CTAs are certain
* messages that are shown to users in the UI.
*/
@Authorized()
@RestController('/cta')
export class CtaController {
constructor(private readonly ctaService: CtaService) {}

View file

@ -11,7 +11,7 @@ export class DebugController {
private readonly workflowRepository: WorkflowRepository,
) {}
@Get('/multi-main-setup')
@Get('/multi-main-setup', { skipAuth: true })
async getMultiMainSetupDetails() {
const leaderKey = await this.orchestrationService.multiMainSetup.fetchLeaderKey();

View file

@ -7,7 +7,7 @@ import type {
} from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { Authorized, Get, Middleware, RestController } from '@/decorators';
import { Get, Middleware, RestController } from '@/decorators';
import { getBase } from '@/WorkflowExecuteAdditionalData';
import { DynamicNodeParametersService } from '@/services/dynamicNodeParameters.service';
import { DynamicNodeParametersRequest } from '@/requests';
@ -21,17 +21,12 @@ const assertMethodName: RequestHandler = (req, res, next) => {
next();
};
@Authorized()
@RestController('/dynamic-node-parameters')
export class DynamicNodeParametersController {
constructor(private readonly service: DynamicNodeParametersService) {}
@Middleware()
parseQueryParams(
req: DynamicNodeParametersRequest.BaseRequest,
res: Response,
next: NextFunction,
) {
parseQueryParams(req: DynamicNodeParametersRequest.BaseRequest, _: Response, next: NextFunction) {
const { credentials, currentNodeParameters, nodeTypeAndVersion } = req.query;
if (!nodeTypeAndVersion) {
throw new BadRequestError('Parameter nodeTypeAndVersion is required.');

View file

@ -7,7 +7,7 @@ import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { License } from '@/License';
import { LICENSE_FEATURES, inE2ETests } from '@/constants';
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
import { Patch, Post, RestController } from '@/decorators';
import type { UserSetupPayload } from '@/requests';
import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces';
import { MfaService } from '@/Mfa/mfa.service';
@ -60,7 +60,6 @@ type PushRequest = Request<
}
>;
@NoAuthRequired()
@RestController('/e2e')
export class E2EController {
private enabledFeatures: Record<BooleanLicenseFeature, boolean> = {
@ -97,7 +96,7 @@ export class E2EController {
this.enabledFeatures[feature] ?? false;
}
@Post('/reset')
@Post('/reset', { skipAuth: true })
async reset(req: ResetRequest) {
this.resetFeatures();
await this.resetLogStreaming();
@ -107,18 +106,18 @@ export class E2EController {
await this.setupUserManagement(req.body.owner, req.body.members, req.body.admin);
}
@Post('/push')
@Post('/push', { skipAuth: true })
async pushSend(req: PushRequest) {
this.push.broadcast(req.body.type, req.body.data);
}
@Patch('/feature')
@Patch('/feature', { skipAuth: true })
setFeature(req: Request<{}, {}, { feature: BooleanLicenseFeature; enabled: boolean }>) {
const { enabled, feature } = req.body;
this.enabledFeatures[feature] = enabled;
}
@Patch('/queue-mode')
@Patch('/queue-mode', { skipAuth: true })
async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) {
const { enabled } = req.body;
config.set('executions.mode', enabled ? 'queue' : 'regular');

View file

@ -3,7 +3,7 @@ import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { Authorized, NoAuthRequired, Post, GlobalScope, RestController } from '@/decorators';
import { Post, GlobalScope, RestController } from '@/decorators';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { UserRequest } from '@/requests';
import { License } from '@/License';
@ -19,7 +19,6 @@ import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { InternalHooks } from '@/InternalHooks';
import { ExternalHooks } from '@/ExternalHooks';
@Authorized()
@RestController('/invitations')
export class InvitationController {
constructor(
@ -120,8 +119,7 @@ export class InvitationController {
/**
* Fill out user shell with first name, last name, and password.
*/
@NoAuthRequired()
@Post('/:id/accept')
@Post('/:id/accept', { skipAuth: true })
async acceptInvitation(req: UserRequest.Update, res: Response) {
const { id: inviteeId } = req.params;

View file

@ -4,7 +4,7 @@ import { Response } from 'express';
import { randomBytes } from 'crypto';
import { AuthService } from '@/auth/auth.service';
import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators';
import { Delete, Get, Patch, Post, RestController } from '@/decorators';
import { PasswordUtility } from '@/services/password.utility';
import { validateEntity } from '@/GenericHelpers';
import type { User } from '@db/entities/User';
@ -23,7 +23,6 @@ import { InternalHooks } from '@/InternalHooks';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserRepository } from '@/databases/repositories/user.repository';
@Authorized()
@RestController('/me')
export class MeController {
constructor(

View file

@ -1,9 +1,8 @@
import { Authorized, Delete, Get, Post, RestController } from '@/decorators';
import { Delete, Get, Post, RestController } from '@/decorators';
import { AuthenticatedRequest, MFA } from '@/requests';
import { MfaService } from '@/Mfa/mfa.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@Authorized()
@RestController('/mfa')
export class MFAController {
constructor(private mfaService: MfaService) {}

View file

@ -2,11 +2,10 @@ import { readFile } from 'fs/promises';
import get from 'lodash/get';
import { Request } from 'express';
import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import { Authorized, Post, RestController } from '@/decorators';
import { Post, RestController } from '@/decorators';
import config from '@/config';
import { NodeTypes } from '@/NodeTypes';
@Authorized()
@RestController('/node-types')
export class NodeTypesController {
constructor(private readonly nodeTypes: NodeTypes) {}

View file

@ -5,7 +5,7 @@ import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import { createHmac } from 'crypto';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Authorized, Get, RestController } from '@/decorators';
import { Get, RestController } from '@/decorators';
import { OAuthRequest } from '@/requests';
import { sendErrorResponse } from '@/ResponseHelper';
import { AbstractOAuthController } from './abstractOAuth.controller';
@ -29,7 +29,6 @@ const algorithmMap = {
/* eslint-enable */
} as const;
@Authorized()
@RestController('/oauth1-credential')
export class OAuth1CredentialController extends AbstractOAuthController {
override oauthVersion = 1;

View file

@ -8,7 +8,7 @@ import omit from 'lodash/omit';
import set from 'lodash/set';
import split from 'lodash/split';
import { ApplicationError, jsonParse, jsonStringify } from 'n8n-workflow';
import { Authorized, Get, RestController } from '@/decorators';
import { Get, RestController } from '@/decorators';
import { OAuthRequest } from '@/requests';
import { AbstractOAuthController } from './abstractOAuth.controller';
@ -17,7 +17,6 @@ interface CsrfStateParam {
token: string;
}
@Authorized()
@RestController('/oauth2-credential')
export class OAuth2CredentialController extends AbstractOAuthController {
override oauthVersion = 2;

View file

@ -1,9 +1,8 @@
import { Authorized, Post, RestController, GlobalScope } from '@/decorators';
import { Post, RestController, GlobalScope } from '@/decorators';
import { OrchestrationRequest } from '@/requests';
import { OrchestrationService } from '@/services/orchestration.service';
import { License } from '@/License';
@Authorized()
@RestController('/orchestration')
export class OrchestrationController {
constructor(
@ -12,7 +11,7 @@ export class OrchestrationController {
) {}
/**
* These endpoints do not return anything, they just trigger the messsage to
* These endpoints do not return anything, they just trigger the message to
* the workers to respond on Redis with their status.
*/
@GlobalScope('orchestration:read')

View file

@ -4,7 +4,7 @@ import { Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { validateEntity } from '@/GenericHelpers';
import { Authorized, Post, RestController } from '@/decorators';
import { GlobalScope, Post, RestController } from '@/decorators';
import { PasswordUtility } from '@/services/password.utility';
import { OwnerRequest } from '@/requests';
import { SettingsRepository } from '@db/repositories/settings.repository';
@ -15,7 +15,6 @@ import { Logger } from '@/Logger';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalHooks } from '@/InternalHooks';
@Authorized('global:owner')
@RestController('/owner')
export class OwnerController {
constructor(
@ -33,24 +32,19 @@ export class OwnerController {
* Promote a shell into the owner of the n8n instance,
* and enable `isInstanceOwnerSetUp` setting.
*/
@Post('/setup')
@Post('/setup', { skipAuth: true })
async setupOwner(req: OwnerRequest.Post, res: Response) {
const { email, firstName, lastName, password } = req.body;
const { id: userId } = req.user;
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to claim instance ownership failed because instance owner already exists',
{
userId,
},
);
throw new BadRequestError('Instance owner already setup');
}
if (!email || !validator.isEmail(email)) {
this.logger.debug('Request to claim instance ownership failed because of invalid email', {
userId,
invalidEmail: email,
});
throw new BadRequestError('Invalid email address');
@ -61,25 +55,24 @@ export class OwnerController {
if (!firstName || !lastName) {
this.logger.debug(
'Request to claim instance ownership failed because of missing first name or last name in payload',
{ userId, payload: req.body },
{ payload: req.body },
);
throw new BadRequestError('First and last names are mandatory');
}
let owner = req.user;
Object.assign(owner, {
email,
firstName,
lastName,
password: await this.passwordUtility.hash(validPassword),
let owner = await this.userRepository.findOneOrFail({
where: { role: 'global:owner' },
});
owner.email = email;
owner.firstName = firstName;
owner.lastName = lastName;
owner.password = await this.passwordUtility.hash(validPassword);
await validateEntity(owner);
owner = await this.userRepository.save(owner, { transaction: false });
this.logger.info('Owner was set up successfully', { userId });
this.logger.info('Owner was set up successfully');
await this.settingsRepository.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
@ -88,19 +81,19 @@ export class OwnerController {
config.set('userManagement.isInstanceOwnerSetUp', true);
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId });
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully');
this.authService.issueCookie(res, owner);
void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });
void this.internalHooks.onInstanceOwnerSetup({ user_id: owner.id });
return await this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true });
}
@Post('/dismiss-banner')
@GlobalScope('banner:dismiss')
async dismissBanner(req: OwnerRequest.DismissBanner) {
const bannerName = 'banner' in req.body ? (req.body.banner as string) : '';
const response = await this.settingsRepository.dismissBanner({ bannerName });
return response;
return await this.settingsRepository.dismissBanner({ bannerName });
}
}

View file

@ -50,6 +50,7 @@ export class PasswordResetController {
*/
@Post('/forgot-password', {
middlewares: !inTest ? [throttle] : [],
skipAuth: true,
})
async forgotPassword(req: PasswordResetRequest.Email) {
if (!this.mailer.isEmailSetUp) {
@ -150,7 +151,7 @@ export class PasswordResetController {
/**
* Verify password reset token and user ID.
*/
@Get('/resolve-password-token')
@Get('/resolve-password-token', { skipAuth: true })
async resolvePasswordToken(req: PasswordResetRequest.Credentials) {
const { token } = req.query;
@ -182,7 +183,7 @@ export class PasswordResetController {
/**
* Verify password reset token and update password.
*/
@Post('/change-password')
@Post('/change-password', { skipAuth: true })
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token, password, mfaToken } = req.body;

View file

@ -1,20 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import config from '@/config';
import {
Authorized,
Delete,
Get,
Middleware,
Patch,
Post,
RestController,
GlobalScope,
} from '@/decorators';
import { Delete, Get, Middleware, Patch, Post, RestController, GlobalScope } from '@/decorators';
import { TagService } from '@/services/tag.service';
import { TagsRequest } from '@/requests';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@Authorized()
@RestController('/tags')
export class TagsController {
private config = config;

View file

@ -1,7 +1,7 @@
import type { Request } from 'express';
import { join } from 'path';
import { access } from 'fs/promises';
import { Authorized, Get, RestController } from '@/decorators';
import { Get, RestController } from '@/decorators';
import config from '@/config';
import { NODES_BASE_DIR } from '@/constants';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -15,7 +15,6 @@ export declare namespace TranslationRequest {
export type Credential = Request<{}, {}, {}, { credentialType: string }>;
}
@Authorized()
@RestController('/')
export class TranslationController {
constructor(private readonly credentialTypes: CredentialTypes) {}

View file

@ -4,15 +4,7 @@ import { AuthService } from '@/auth/auth.service';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import {
GlobalScope,
Authorized,
Delete,
Get,
RestController,
Patch,
Licensed,
} from '@/decorators';
import { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators';
import {
ListQuery,
UserRequest,
@ -35,7 +27,6 @@ import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { validateEntity } from '@/GenericHelpers';
@Authorized()
@RestController('/users')
export class UsersController {
constructor(

View file

@ -11,14 +11,13 @@ import { License } from '@/License';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { OwnershipService } from '@/services/ownership.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { Authorized, Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators';
import { Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';
@Authorized()
@RestController('/credentials')
export class CredentialsController {
constructor(

View file

@ -1,15 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { CONTROLLER_AUTH_ROLES } from './constants';
import type { AuthRoleMetadata } from './types';
export function Authorized(authRole: AuthRoleMetadata[string] = 'any'): Function {
return function (target: Function | object, handlerName?: string) {
const controllerClass = handlerName ? target.constructor : target;
const authRoles = (Reflect.getMetadata(CONTROLLER_AUTH_ROLES, controllerClass) ??
{}) as AuthRoleMetadata;
authRoles[handlerName ?? '*'] = authRole;
Reflect.defineMetadata(CONTROLLER_AUTH_ROLES, authRoles, controllerClass);
};
}
export const NoAuthRequired = () => Authorized('none');

View file

@ -5,6 +5,8 @@ import type { Method, RouteMetadata } from './types';
interface RouteOptions {
middlewares?: RequestHandler[];
usesTemplates?: boolean;
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
skipAuth?: boolean;
}
const RouteFactory =
@ -20,6 +22,7 @@ const RouteFactory =
middlewares: options.middlewares ?? [],
handlerName: String(handlerName),
usesTemplates: options.usesTemplates ?? false,
skipAuth: options.skipAuth ?? false,
});
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
};

View file

@ -1,6 +1,5 @@
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH';
export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES';
export const CONTROLLER_AUTH_ROLES = 'CONTROLLER_AUTH_ROLES';
export const CONTROLLER_LICENSE_FEATURES = 'CONTROLLER_LICENSE_FEATURES';
export const CONTROLLER_REQUIRED_SCOPES = 'CONTROLLER_REQUIRED_SCOPES';

View file

@ -1,4 +1,3 @@
export { Authorized, NoAuthRequired } from './Authorized';
export { RestController } from './RestController';
export { Get, Post, Put, Patch, Delete } from './Route';
export { Middleware } from './Middleware';

View file

@ -12,7 +12,6 @@ import { License } from '@/License';
import type { AuthenticatedRequest } from '@/requests';
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
import {
CONTROLLER_AUTH_ROLES,
CONTROLLER_BASE_PATH,
CONTROLLER_LICENSE_FEATURES,
CONTROLLER_MIDDLEWARES,
@ -20,7 +19,6 @@ import {
CONTROLLER_ROUTES,
} from './constants';
import type {
AuthRoleMetadata,
Controller,
LicenseMetadata,
MiddlewareMetadata,
@ -74,9 +72,6 @@ export const registerController = (app: Application, controllerClass: Class<obje
extra: { controllerName: controllerClass.name },
});
const authRoles = Reflect.getMetadata(CONTROLLER_AUTH_ROLES, controllerClass) as
| AuthRoleMetadata
| undefined;
const routes = Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) as RouteMetadata[];
const licenseFeatures = Reflect.getMetadata(CONTROLLER_LICENSE_FEATURES, controllerClass) as
| LicenseMetadata
@ -99,15 +94,15 @@ export const registerController = (app: Application, controllerClass: Class<obje
const authService = Container.get(AuthService);
routes.forEach(
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => {
const authRole = authRoles?.[handlerName] ?? authRoles?.['*'];
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates, skipAuth }) => {
const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*'];
const scopes = requiredScopes?.[handlerName] ?? requiredScopes?.['*'];
const handler = async (req: Request, res: Response) =>
await controller[handlerName](req, res);
router[method](
path,
...(authRole ? [authService.createAuthMiddleware(authRole)] : []),
// eslint-disable-next-line @typescript-eslint/unbound-method
...(skipAuth ? [] : [authService.authMiddleware]),
...(features ? [createLicenseMiddleware(features)] : []),
...(scopes ? [createGlobalScopeMiddleware(scopes)] : []),
...controllerMiddlewares,

View file

@ -1,13 +1,9 @@
import type { Request, Response, RequestHandler } from 'express';
import type { GlobalRole } from '@db/entities/User';
import type { BooleanLicenseFeature } from '@/Interfaces';
import type { Scope } from '@n8n/permissions';
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
export type AuthRole = GlobalRole | 'any' | 'none';
export type AuthRoleMetadata = Record<string, AuthRole>;
export type LicenseMetadata = Record<string, BooleanLicenseFeature[]>;
export type ScopeMetadata = Record<string, Scope[]>;
@ -22,6 +18,7 @@ export interface RouteMetadata {
handlerName: string;
middlewares: RequestHandler[];
usesTemplates: boolean;
skipAuth: boolean;
}
export type Controller = Record<

View file

@ -1,6 +1,6 @@
import type { PullResult } from 'simple-git';
import express from 'express';
import { Authorized, Get, Post, Patch, RestController, GlobalScope } from '@/decorators';
import { Get, Post, Patch, RestController, GlobalScope } from '@/decorators';
import {
sourceControlLicensedMiddleware,
sourceControlLicensedAndEnabledMiddleware,
@ -17,7 +17,6 @@ import { getRepoType } from './sourceControlHelper.ee';
import { SourceControlGetStatus } from './types/sourceControlGetStatus';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@Authorized()
@RestController('/source-control')
export class SourceControlController {
constructor(
@ -26,8 +25,7 @@ export class SourceControlController {
private readonly internalHooks: InternalHooks,
) {}
@Authorized('none')
@Get('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
@Get('/preferences', { middlewares: [sourceControlLicensedMiddleware], skipAuth: true })
async getPreferences(): Promise<SourceControlPreferences> {
// returns the settings with the privateKey property redacted
return this.sourceControlPreferencesService.getPreferences();
@ -151,7 +149,6 @@ export class SourceControlController {
}
}
@Authorized('any')
@Get('/get-branches', { middlewares: [sourceControlLicensedMiddleware] })
async getBranches() {
try {
@ -212,7 +209,6 @@ export class SourceControlController {
}
}
@Authorized('any')
@Get('/get-status', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async getStatus(req: SourceControlRequest.GetStatus) {
try {
@ -225,7 +221,6 @@ export class SourceControlController {
}
}
@Authorized('any')
@Get('/status', { middlewares: [sourceControlLicensedMiddleware] })
async status(req: SourceControlRequest.GetStatus) {
try {

View file

@ -1,21 +1,11 @@
import { VariablesRequest } from '@/requests';
import {
Authorized,
Delete,
Get,
Licensed,
Patch,
Post,
GlobalScope,
RestController,
} from '@/decorators';
import { Delete, Get, Licensed, Patch, Post, GlobalScope, RestController } from '@/decorators';
import { VariablesService } from './variables.service.ee';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { VariableValidationError } from '@/errors/variable-validation.error';
import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error';
@Authorized()
@RestController('/variables')
export class VariablesController {
constructor(private readonly variablesService: VariablesService) {}

View file

@ -5,7 +5,7 @@ import type {
} from 'n8n-workflow';
import { MessageEventBusDestinationTypeNames } from 'n8n-workflow';
import { RestController, Get, Post, Delete, Authorized, GlobalScope } from '@/decorators';
import { RestController, Get, Post, Delete, GlobalScope } from '@/decorators';
import { AuthenticatedRequest } from '@/requests';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -52,7 +52,6 @@ const isMessageEventBusDestinationOptions = (
// Controller
// ----------------------------------------
@Authorized()
@RestController('/eventbus')
export class EventBusControllerEE {
constructor(private readonly eventBus: MessageEventBus) {}

View file

@ -2,7 +2,7 @@ import express from 'express';
import type { IRunExecutionData } from 'n8n-workflow';
import { EventMessageTypeNames } from 'n8n-workflow';
import { RestController, Get, Post, Authorized, GlobalScope } from '@/decorators';
import { RestController, Get, Post, GlobalScope } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { isEventMessageOptions } from './EventMessageClasses/AbstractEventMessage';
@ -33,7 +33,6 @@ const isWithQueryString = (candidate: unknown): candidate is { query: string } =
// Controller
// ----------------------------------------
@Authorized()
@RestController('/eventbus')
export class EventBusController {
constructor(

View file

@ -1,7 +1,7 @@
import type { GetManyActiveFilter } from './execution.types';
import { ExecutionRequest } from './execution.types';
import { ExecutionService } from './execution.service';
import { Authorized, Get, Post, RestController } from '@/decorators';
import { Get, Post, RestController } from '@/decorators';
import { EnterpriseExecutionsService } from './execution.service.ee';
import { License } from '@/License';
import { WorkflowSharingService } from '@/workflows/workflowSharing.service';
@ -11,7 +11,6 @@ import { jsonParse } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ActiveExecutionService } from './active-execution.service';
@Authorized()
@RestController('/executions')
export class ExecutionsController {
private readonly isQueueMode = config.getEnv('executions.mode') === 'queue';

View file

@ -1,8 +1,7 @@
import { Authorized, Get, Post, GlobalScope, RestController } from '@/decorators';
import { Get, Post, GlobalScope, RestController } from '@/decorators';
import { LicenseRequest } from '@/requests';
import { LicenseService } from './license.service';
@Authorized()
@RestController('/license')
export class LicenseController {
constructor(private readonly licenseService: LicenseService) {}

View file

@ -2,6 +2,7 @@ import type { Scope } from '@n8n/permissions';
export const ownerPermissions: Scope[] = [
'auditLogs:manage',
'banner:dismiss',
'credential:create',
'credential:read',
'credential:update',

View file

@ -104,7 +104,7 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => {
const pushValidationMiddleware: RequestHandler = async (
req: SSEPushRequest | WebSocketPushRequest,
res,
_,
next,
) => {
const ws = req.ws;
@ -127,7 +127,8 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => {
const authService = Container.get(AuthService);
app.use(
endpoint,
authService.createAuthMiddleware('any'),
// eslint-disable-next-line @typescript-eslint/unbound-method
authService.authMiddleware,
pushValidationMiddleware,
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) => push.handleRequest(req, res),
);

View file

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

View file

@ -3,8 +3,7 @@ import { validate } from 'class-validator';
import type { PostBindingContext } from 'samlify/types/src/entity';
import url from 'url';
import { Authorized, Get, NoAuthRequired, Post, RestController, GlobalScope } from '@/decorators';
import { Get, Post, RestController, GlobalScope } from '@/decorators';
import { AuthService } from '@/auth/auth.service';
import { AuthenticatedRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
@ -31,7 +30,6 @@ import { SamlService } from '../saml.service.ee';
import { SamlConfiguration } from '../types/requests';
import { getInitSSOFormView } from '../views/initSsoPost';
@Authorized()
@RestController('/sso/saml')
export class SamlController {
constructor(
@ -41,8 +39,7 @@ export class SamlController {
private readonly internalHooks: InternalHooks,
) {}
@NoAuthRequired()
@Get(SamlUrls.metadata)
@Get(SamlUrls.metadata, { skipAuth: true })
async getServiceProviderMetadata(_: express.Request, res: express.Response) {
return res
.header('Content-Type', 'text/xml')
@ -53,7 +50,6 @@ export class SamlController {
* GET /sso/saml/config
* Return SAML config
*/
@Authorized('any')
@Get(SamlUrls.config, { middlewares: [samlLicensedMiddleware] })
async configGet() {
const prefs = this.samlService.samlPreferences;
@ -101,8 +97,7 @@ export class SamlController {
* GET /sso/saml/acs
* Assertion Consumer Service endpoint
*/
@NoAuthRequired()
@Get(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] })
@Get(SamlUrls.acs, { middlewares: [samlLicensedMiddleware], skipAuth: true })
async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) {
return await this.acsHandler(req, res, 'redirect');
}
@ -111,8 +106,7 @@ export class SamlController {
* POST /sso/saml/acs
* Assertion Consumer Service endpoint
*/
@NoAuthRequired()
@Post(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] })
@Post(SamlUrls.acs, { middlewares: [samlLicensedMiddleware], skipAuth: true })
async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) {
return await this.acsHandler(req, res, 'post');
}
@ -177,8 +171,7 @@ export class SamlController {
* Access URL for implementing SP-init SSO
* This endpoint is available if SAML is licensed and enabled
*/
@NoAuthRequired()
@Get(SamlUrls.initSSO, { middlewares: [samlLicensedAndEnabledMiddleware] })
@Get(SamlUrls.initSSO, { middlewares: [samlLicensedAndEnabledMiddleware], skipAuth: true })
async initSsoGet(req: express.Request, res: express.Response) {
let redirectUrl = '';
try {

View file

@ -1,4 +1,4 @@
import { Authorized, RestController, Get, Middleware } from '@/decorators';
import { RestController, Get, Middleware } from '@/decorators';
import { WorkflowHistoryRequest } from '@/requests';
import { WorkflowHistoryService } from './workflowHistory.service.ee';
import { Request, Response, NextFunction } from 'express';
@ -11,7 +11,6 @@ import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-v
const DEFAULT_TAKE = 20;
@Authorized()
@RestController('/workflow-history')
export class WorkflowHistoryController {
constructor(private readonly historyService: WorkflowHistoryService) {}

View file

@ -8,7 +8,7 @@ import * as ResponseHelper from '@/ResponseHelper';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import type { IWorkflowResponse } from '@/Interfaces';
import config from '@/config';
import { Authorized, Delete, Get, Patch, Post, Put, RestController } from '@/decorators';
import { Delete, Get, Patch, Post, Put, RestController } from '@/decorators';
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
@ -39,7 +39,6 @@ import { WorkflowExecutionService } from './workflowExecution.service';
import { WorkflowSharingService } from './workflowSharing.service';
import { UserManagementMailer } from '@/UserManagement/email';
@Authorized()
@RestController('/workflows')
export class WorkflowsController {
constructor(

View file

@ -17,14 +17,12 @@ describe('Auth Middleware', () => {
['PATCH', '/me'],
['PATCH', '/me/password'],
['POST', '/me/survey'],
['POST', '/owner/setup'],
];
/** Routes requiring a valid `n8n-auth` cookie for an owner. */
const ROUTES_REQUIRING_AUTHORIZATION: Readonly<Array<[string, string]>> = [
['POST', '/invitations'],
['DELETE', '/users/123'],
['POST', '/owner/setup'],
];
describe('Routes requiring Authentication', () => {

View file

@ -1,5 +1,4 @@
import validator from 'validator';
import type { SuperAgentTest } from 'supertest';
import config from '@/config';
import type { User } from '@db/entities/User';
@ -18,11 +17,9 @@ import Container from 'typedi';
const testServer = utils.setupTestServer({ endpointGroups: ['owner'] });
let ownerShell: User;
let authOwnerShellAgent: SuperAgentTest;
beforeEach(async () => {
ownerShell = await createUserShell('global:owner');
authOwnerShellAgent = testServer.authAgentFor(ownerShell);
config.set('userManagement.isInstanceOwnerSetUp', false);
});
@ -39,7 +36,7 @@ describe('POST /owner/setup', () => {
password: randomValidPassword(),
};
const response = await authOwnerShellAgent.post('/owner/setup').send(newOwnerData);
const response = await testServer.authlessAgent.post('/owner/setup').send(newOwnerData);
expect(response.statusCode).toBe(200);
@ -88,7 +85,7 @@ describe('POST /owner/setup', () => {
password: randomValidPassword(),
};
const response = await authOwnerShellAgent.post('/owner/setup').send(newOwnerData);
const response = await testServer.authlessAgent.post('/owner/setup').send(newOwnerData);
expect(response.statusCode).toBe(200);
@ -150,7 +147,7 @@ describe('POST /owner/setup', () => {
test('should fail with invalid inputs', async () => {
for (const invalidPayload of INVALID_POST_OWNER_PAYLOADS) {
const response = await authOwnerShellAgent.post('/owner/setup').send(invalidPayload);
const response = await testServer.authlessAgent.post('/owner/setup').send(invalidPayload);
expect(response.statusCode).toBe(400);
}
});

View file

@ -39,7 +39,7 @@ describe('AuthService', () => {
config.set('userManagement.jwtRefreshTimeoutHours', 0);
});
describe('createAuthMiddleware', () => {
describe('authMiddleware', () => {
const req = mock<AuthenticatedRequest>({ cookies: {}, user: undefined });
const res = mock<Response>();
const next = jest.fn() as NextFunction;
@ -48,64 +48,34 @@ describe('AuthService', () => {
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();
});
it('should 401 if no cookie is set', async () => {
req.cookies[AUTH_COOKIE_NAME] = undefined;
await authService.authMiddleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
describe('authRole = any', () => {
const authMiddleware = authService.createAuthMiddleware('any');
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);
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',
});
});
await authService.authMiddleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
});
describe('authRole = global:owner', () => {
const authMiddleware = authService.createAuthMiddleware('global:owner');
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);
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);
await authService.authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
httpOnly: true,
maxAge: 604800000,
sameSite: 'lax',
});
});
});

View file

@ -93,11 +93,15 @@ describe('OwnerController', () => {
});
const res = mock<Response>();
configGetSpy.mockReturnValue(false);
userRepository.findOneOrFail.calledWith(anyObject()).mockResolvedValue(user);
userRepository.save.calledWith(anyObject()).mockResolvedValue(user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
await controller.setupOwner(req, res);
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
where: { role: 'global:owner' },
});
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
expect(authService.issueCookie).toHaveBeenCalledWith(res, user);
});