mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-21 02:56:40 -08:00
✨ Make it possible to secure n8n via basic auth
This commit is contained in:
parent
a8b2829e84
commit
f3d84fc29e
|
@ -53,6 +53,22 @@ docker run -it --rm \
|
|||
n8n start --tunnel
|
||||
```
|
||||
|
||||
|
||||
## Securing n8n
|
||||
|
||||
By default n8n can be accessed by everybody. This is OK if you have it only running
|
||||
locally buy if you deploy it on a server which is accessible from the web you have
|
||||
to make sure that n8n is protected!
|
||||
Right now we have very basic protection via basic-auth in place. It can be activated
|
||||
by setting the following environment variables:
|
||||
|
||||
```
|
||||
N8N_BASIC_AUTH_ACTIVE=true
|
||||
N8N_BASIC_AUTH_USER=<USER>
|
||||
N8N_BASIC_AUTH_PASSWORD=<PASSWORD>
|
||||
```
|
||||
|
||||
|
||||
## Persist data
|
||||
|
||||
The workflow data gets by default saved in an SQLite database in the user
|
||||
|
@ -73,11 +89,6 @@ By default n8n uses SQLite to save credentials, past executions and workflows.
|
|||
n8n however also supports MongoDB and PostgresDB. To use them simply a few
|
||||
environment variables have to be set.
|
||||
|
||||
To avoid passing sensitive information via environment variables "_FILE" may be
|
||||
appended to the database environment variables (for example "DB_POSTGRESDB_PASSWORD_FILE").
|
||||
It will then load the data from a file with the given name. That makes it possible to
|
||||
load data easily from Docker- and Kubernetes-Secrets.
|
||||
|
||||
It is important to still persist the data in the `/root/.n8` folder. The reason
|
||||
is that it contains n8n user data. That is the name of the webhook
|
||||
(in case) the n8n tunnel gets used and even more important the encryption key
|
||||
|
@ -133,6 +144,25 @@ docker run -it --rm \
|
|||
n8n start
|
||||
```
|
||||
|
||||
|
||||
## Passing Senstive Data via File
|
||||
|
||||
To avoid passing sensitive information via environment variables "_FILE" may be
|
||||
appended to some environment variables. It will then load the data from a file
|
||||
with the given name. That makes it possible to load data easily from
|
||||
Docker- and Kubernetes-Secrets.
|
||||
|
||||
The following environment variables support file input:
|
||||
- DB_MONGODB_CONNECTION_URL
|
||||
- DB_POSTGRESDB_DATABASE_FILE
|
||||
- DB_POSTGRESDB_HOST_FILE
|
||||
- DB_POSTGRESDB_PASSWORD_FILE
|
||||
- DB_POSTGRESDB_PORT_FILE
|
||||
- DB_POSTGRESDB_USER_FILE
|
||||
- N8N_BASIC_AUTH_PASSWORD_FILE
|
||||
- N8N_BASIC_AUTH_USER_FILE
|
||||
|
||||
|
||||
## License
|
||||
|
||||
n8n is licensed under **Apache 2.0 with Commons Clause**
|
||||
|
|
|
@ -67,6 +67,20 @@ To use it simply start n8n with `--tunnel`
|
|||
n8n start --tunnel
|
||||
```
|
||||
|
||||
### Securing n8n
|
||||
|
||||
By default n8n can be accessed by everybody. This is OK if you have it only running
|
||||
locally buy if you deploy it on a server which is accessible from the web you have
|
||||
to make sure that n8n is protected!
|
||||
Right now we have very basic protection via basic-auth in place. It can be activated
|
||||
by setting the following environment variables:
|
||||
|
||||
```
|
||||
N8N_BASIC_AUTH_ACTIVE=true
|
||||
N8N_BASIC_AUTH_USER=<USER>
|
||||
N8N_BASIC_AUTH_PASSWORD=<PASSWORD>
|
||||
```
|
||||
|
||||
|
||||
### Start with other Database
|
||||
|
||||
|
@ -74,11 +88,6 @@ By default n8n uses SQLite to save credentials, past executions and workflows.
|
|||
n8n however also supports MongoDB and PostgresDB. To use them simply a few
|
||||
environment variables have to be set.
|
||||
|
||||
To avoid passing sensitive information via environment variables "_FILE" may be
|
||||
appended to the database environment variables (for example "DB_POSTGRESDB_PASSWORD_FILE").
|
||||
It will then load the data from a file with the given name. That makes it possible to
|
||||
load data easily from Docker- and Kubernetes-Secrets.
|
||||
|
||||
|
||||
#### Start with MongoDB as Database
|
||||
|
||||
|
@ -125,6 +134,24 @@ n8n start
|
|||
```
|
||||
|
||||
|
||||
## Passing Senstive Data via File
|
||||
|
||||
To avoid passing sensitive information via environment variables "_FILE" may be
|
||||
appended to some environment variables. It will then load the data from a file
|
||||
with the given name. That makes it possible to load data easily from
|
||||
Docker- and Kubernetes-Secrets.
|
||||
|
||||
The following environment variables support file input:
|
||||
- DB_MONGODB_CONNECTION_URL
|
||||
- DB_POSTGRESDB_DATABASE_FILE
|
||||
- DB_POSTGRESDB_HOST_FILE
|
||||
- DB_POSTGRESDB_PASSWORD_FILE
|
||||
- DB_POSTGRESDB_PORT_FILE
|
||||
- DB_POSTGRESDB_USER_FILE
|
||||
- N8N_BASIC_AUTH_PASSWORD_FILE
|
||||
- N8N_BASIC_AUTH_USER_FILE
|
||||
|
||||
|
||||
## Execute Workflow from CLI
|
||||
|
||||
Workflows can not just be started by triggers, webhooks or manually via the
|
||||
|
|
|
@ -23,7 +23,7 @@ import { promisify } from "util";
|
|||
const tunnel = promisify(localtunnel);
|
||||
|
||||
let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined;
|
||||
|
||||
let processExistCode = 0;
|
||||
|
||||
/**
|
||||
* Opens the UI in browser
|
||||
|
@ -68,89 +68,97 @@ flag "--init" to fix this problem!`);
|
|||
|
||||
// Wrap that the process does not close but we can still use async
|
||||
(async () => {
|
||||
// Start directly with the init of the database to improve startup time
|
||||
const startDbInitPromise = Db.init();
|
||||
try {
|
||||
// Start directly with the init of the database to improve startup time
|
||||
const startDbInitPromise = Db.init();
|
||||
|
||||
// Make sure the settings exist
|
||||
const userSettings = await UserSettings.prepareUserSettings();
|
||||
// Make sure the settings exist
|
||||
const userSettings = await UserSettings.prepareUserSettings();
|
||||
|
||||
// Load all node and credential types
|
||||
const loadNodesAndCredentials = LoadNodesAndCredentials();
|
||||
await loadNodesAndCredentials.init();
|
||||
// Load all node and credential types
|
||||
const loadNodesAndCredentials = LoadNodesAndCredentials();
|
||||
await loadNodesAndCredentials.init();
|
||||
|
||||
// Add the found types to an instance other parts of the application can use
|
||||
const nodeTypes = NodeTypes();
|
||||
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
|
||||
const credentialTypes = CredentialTypes();
|
||||
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
|
||||
// Add the found types to an instance other parts of the application can use
|
||||
const nodeTypes = NodeTypes();
|
||||
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
|
||||
const credentialTypes = CredentialTypes();
|
||||
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
|
||||
|
||||
// Wait till the database is ready
|
||||
await startDbInitPromise;
|
||||
// Wait till the database is ready
|
||||
await startDbInitPromise;
|
||||
|
||||
if (args.options.tunnel !== undefined) {
|
||||
console.log('\nWaiting for tunnel ...');
|
||||
if (args.options.tunnel !== undefined) {
|
||||
console.log('\nWaiting for tunnel ...');
|
||||
|
||||
if (userSettings.tunnelSubdomain === undefined) {
|
||||
// When no tunnel subdomain did exist yet create a new random one
|
||||
const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => {
|
||||
return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length));
|
||||
}).join('');
|
||||
if (userSettings.tunnelSubdomain === undefined) {
|
||||
// When no tunnel subdomain did exist yet create a new random one
|
||||
const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => {
|
||||
return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length));
|
||||
}).join('');
|
||||
|
||||
await UserSettings.writeUserSettings(userSettings);
|
||||
await UserSettings.writeUserSettings(userSettings);
|
||||
}
|
||||
|
||||
const tunnelSettings: localtunnel.TunnelConfig = {
|
||||
host: 'https://hooks.n8n.cloud',
|
||||
subdomain: userSettings.tunnelSubdomain,
|
||||
};
|
||||
|
||||
const port = config.get('port') as number;
|
||||
|
||||
// @ts-ignore
|
||||
const webhookTunnel = await tunnel(port, tunnelSettings);
|
||||
|
||||
process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/';
|
||||
console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);
|
||||
}
|
||||
|
||||
const tunnelSettings: localtunnel.TunnelConfig = {
|
||||
host: 'https://hooks.n8n.cloud',
|
||||
subdomain: userSettings.tunnelSubdomain,
|
||||
};
|
||||
await Server.start();
|
||||
|
||||
const port = config.get('port') as number;
|
||||
// Start to get active workflows and run their triggers
|
||||
activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||
await activeWorkflowRunner.init();
|
||||
|
||||
// @ts-ignore
|
||||
const webhookTunnel = await tunnel(port, tunnelSettings);
|
||||
const editorUrl = GenericHelpers.getBaseUrl();
|
||||
console.log(`\nEditor is now accessible via:\n${editorUrl}`);
|
||||
|
||||
process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/';
|
||||
console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);
|
||||
}
|
||||
// Allow to open n8n editor by pressing "o"
|
||||
if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf8');
|
||||
let inputText = '';
|
||||
|
||||
Server.start();
|
||||
|
||||
// Start to get active workflows and run their triggers
|
||||
activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||
await activeWorkflowRunner.init();
|
||||
|
||||
const editorUrl = GenericHelpers.getBaseUrl();
|
||||
console.log(`\nEditor is now accessible via:\n${editorUrl}`);
|
||||
|
||||
// Allow to open n8n editor by pressing "o"
|
||||
if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf8');
|
||||
let inputText = '';
|
||||
|
||||
if (args.options.browser !== undefined) {
|
||||
openBrowser();
|
||||
}
|
||||
console.log(`\nPress "o" to open in Browser.`);
|
||||
process.stdin.on("data", (key) => {
|
||||
if (key === 'o') {
|
||||
if (args.options.browser !== undefined) {
|
||||
openBrowser();
|
||||
inputText = '';
|
||||
} else {
|
||||
// When anything else got pressed, record it and send it on enter into the child process
|
||||
if (key.charCodeAt(0) === 13) {
|
||||
// send to child process and print in terminal
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
console.log(`\nPress "o" to open in Browser.`);
|
||||
process.stdin.on("data", (key) => {
|
||||
if (key === 'o') {
|
||||
openBrowser();
|
||||
inputText = '';
|
||||
} else {
|
||||
// record it and write into terminal
|
||||
inputText += key;
|
||||
process.stdout.write(key);
|
||||
// When anything else got pressed, record it and send it on enter into the child process
|
||||
if (key.charCodeAt(0) === 13) {
|
||||
// send to child process and print in terminal
|
||||
process.stdout.write('\n');
|
||||
inputText = '';
|
||||
} else {
|
||||
// record it and write into terminal
|
||||
inputText += key;
|
||||
process.stdout.write(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`There was an error: ${error.message}`);
|
||||
|
||||
processExistCode = 1;
|
||||
// @ts-ignore
|
||||
process.emit('SIGINT');
|
||||
}
|
||||
})();
|
||||
|
||||
|
@ -161,7 +169,7 @@ flag "--init" to fix this problem!`);
|
|||
setTimeout(() => {
|
||||
// In case that something goes wrong with shutdown we
|
||||
// kill after max. 30 seconds no matter what
|
||||
process.exit();
|
||||
process.exit(processExistCode);
|
||||
}, 30000);
|
||||
|
||||
const removePromises = [];
|
||||
|
@ -175,7 +183,7 @@ flag "--init" to fix this problem!`);
|
|||
|
||||
await Promise.all(removePromises);
|
||||
|
||||
process.exit();
|
||||
process.exit(processExistCode);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -121,6 +121,30 @@ const config = convict({
|
|||
doc: 'HTTP Protocol via which n8n can be reached'
|
||||
},
|
||||
|
||||
security: {
|
||||
basicAuth: {
|
||||
active: {
|
||||
format: 'Boolean',
|
||||
default: false,
|
||||
env: 'N8N_BASIC_AUTH_ACTIVE',
|
||||
doc: 'If basic auth should be activated for editor and REST-API'
|
||||
},
|
||||
user: {
|
||||
format: String,
|
||||
default: '',
|
||||
env: 'N8N_BASIC_AUTH_USER',
|
||||
doc: 'The name of the basic auth user'
|
||||
},
|
||||
password: {
|
||||
format: String,
|
||||
default: '',
|
||||
env: 'N8N_BASIC_AUTH_PASSWORD',
|
||||
doc: 'The password of the basic auth user'
|
||||
},
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
rest: {
|
||||
format: String,
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/basic-auth": "^1.1.2",
|
||||
"@types/compression": "0.0.36",
|
||||
"@types/connect-history-api-fallback": "^1.3.1",
|
||||
"@types/convict": "^4.2.1",
|
||||
|
@ -59,6 +60,7 @@
|
|||
"typescript": "~3.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"basic-auth": "^2.0.1",
|
||||
"body-parser": "^1.18.3",
|
||||
"compression": "^1.7.4",
|
||||
"connect-history-api-fallback": "^1.6.0",
|
||||
|
|
|
@ -45,6 +45,14 @@ export class ReponseError extends Error {
|
|||
}
|
||||
|
||||
|
||||
|
||||
export function basicAuthAuthorizationError(resp: Response, realm: string, message?: string) {
|
||||
resp.statusCode = 401;
|
||||
resp.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
|
||||
resp.end(message);
|
||||
}
|
||||
|
||||
|
||||
export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
|
|
|
@ -61,11 +61,12 @@ import {
|
|||
Not,
|
||||
} from 'typeorm';
|
||||
|
||||
import * as parseUrl from 'parseurl';
|
||||
import * as basicAuth from 'basic-auth';
|
||||
import * as compression from 'compression';
|
||||
import * as config from '../config';
|
||||
// @ts-ignore
|
||||
import * as timezones from 'google-timezones-json';
|
||||
import * as compression from 'compression';
|
||||
import * as parseUrl from 'parseurl';
|
||||
|
||||
|
||||
class App {
|
||||
|
@ -92,7 +93,6 @@ class App {
|
|||
this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean;
|
||||
this.timezone = config.get('generic.timezone') as string;
|
||||
|
||||
this.config();
|
||||
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||
this.testWebhooks = TestWebhooks.getInstance();
|
||||
this.push = Push.getInstance();
|
||||
|
@ -112,8 +112,44 @@ class App {
|
|||
}
|
||||
|
||||
|
||||
private config(): void {
|
||||
async config(): Promise<void> {
|
||||
|
||||
// Check for basic auth credentials if activated
|
||||
const basicAuthActive = config.get('security.basicAuth.active') as boolean;
|
||||
if (basicAuthActive === true) {
|
||||
const basicAuthUser = await GenericHelpers.getConfigValue('security.basicAuth.user') as string;
|
||||
if (basicAuthUser === '') {
|
||||
throw new Error('Basic auth is activated but no user got defined. Please set one!');
|
||||
}
|
||||
|
||||
const basicAuthPassword = await GenericHelpers.getConfigValue('security.basicAuth.password') as string;
|
||||
if (basicAuthPassword === '') {
|
||||
throw new Error('Basic auth is activated but no password got defined. Please set one!');
|
||||
}
|
||||
|
||||
const authIgnoreRegex = new RegExp(`^\/(rest|${this.endpointWebhook}|${this.endpointWebhookTest})\/.*$`)
|
||||
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (req.url.match(authIgnoreRegex)) {
|
||||
return next();
|
||||
}
|
||||
const realm = 'n8n - Editor UI';
|
||||
const basicAuthData = basicAuth(req);
|
||||
|
||||
if (basicAuthData === undefined) {
|
||||
// Authorization data is missing
|
||||
return ResponseHelper.basicAuthAuthorizationError(res, realm, 'Authorization is required!');
|
||||
}
|
||||
|
||||
if (basicAuthData.name !== basicAuthUser || basicAuthData.pass !== basicAuthPassword) {
|
||||
// Provided authentication data is wrong
|
||||
return ResponseHelper.basicAuthAuthorizationError(res, realm, 'Authorization data is wrong!');
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Compress the repsonse data
|
||||
this.app.use(compression());
|
||||
|
||||
// Get push connections
|
||||
|
@ -1036,12 +1072,14 @@ class App {
|
|||
|
||||
}
|
||||
|
||||
export function start() {
|
||||
export async function start(): Promise<void> {
|
||||
const PORT = config.get('port');
|
||||
|
||||
const app = new App().app;
|
||||
const app = new App();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
await app.config();
|
||||
|
||||
app.app.listen(PORT, () => {
|
||||
console.log('n8n ready on port ' + PORT);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue