Make it possible to secure n8n via basic auth

This commit is contained in:
Jan Oberhauser 2019-08-04 14:24:48 +02:00
parent a8b2829e84
commit f3d84fc29e
7 changed files with 223 additions and 86 deletions

View file

@ -53,6 +53,22 @@ docker run -it --rm \
n8n start --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>
```
## Persist data ## Persist data
The workflow data gets by default saved in an SQLite database in the user 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 n8n however also supports MongoDB and PostgresDB. To use them simply a few
environment variables have to be set. 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 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 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 (in case) the n8n tunnel gets used and even more important the encryption key
@ -133,6 +144,25 @@ docker run -it --rm \
n8n start 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 ## License
n8n is licensed under **Apache 2.0 with Commons Clause** n8n is licensed under **Apache 2.0 with Commons Clause**

View file

@ -67,6 +67,20 @@ To use it simply start n8n with `--tunnel`
n8n start --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 ### 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 n8n however also supports MongoDB and PostgresDB. To use them simply a few
environment variables have to be set. 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 #### 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 ## Execute Workflow from CLI
Workflows can not just be started by triggers, webhooks or manually via the Workflows can not just be started by triggers, webhooks or manually via the

View file

@ -23,7 +23,7 @@ import { promisify } from "util";
const tunnel = promisify(localtunnel); const tunnel = promisify(localtunnel);
let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined; let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined;
let processExistCode = 0;
/** /**
* Opens the UI in browser * Opens the UI in browser
@ -68,6 +68,7 @@ flag "--init" to fix this problem!`);
// Wrap that the process does not close but we can still use async // Wrap that the process does not close but we can still use async
(async () => { (async () => {
try {
// Start directly with the init of the database to improve startup time // Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init(); const startDbInitPromise = Db.init();
@ -114,7 +115,7 @@ flag "--init" to fix this problem!`);
console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`); console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);
} }
Server.start(); await Server.start();
// Start to get active workflows and run their triggers // Start to get active workflows and run their triggers
activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
@ -152,6 +153,13 @@ flag "--init" to fix this problem!`);
} }
}); });
} }
} 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(() => { setTimeout(() => {
// In case that something goes wrong with shutdown we // In case that something goes wrong with shutdown we
// kill after max. 30 seconds no matter what // kill after max. 30 seconds no matter what
process.exit(); process.exit(processExistCode);
}, 30000); }, 30000);
const removePromises = []; const removePromises = [];
@ -175,7 +183,7 @@ flag "--init" to fix this problem!`);
await Promise.all(removePromises); await Promise.all(removePromises);
process.exit(); process.exit(processExistCode);
}); });
}); });
}; };

View file

@ -121,6 +121,30 @@ const config = convict({
doc: 'HTTP Protocol via which n8n can be reached' 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: { endpoints: {
rest: { rest: {
format: String, format: String,

View file

@ -39,6 +39,7 @@
"dist" "dist"
], ],
"devDependencies": { "devDependencies": {
"@types/basic-auth": "^1.1.2",
"@types/compression": "0.0.36", "@types/compression": "0.0.36",
"@types/connect-history-api-fallback": "^1.3.1", "@types/connect-history-api-fallback": "^1.3.1",
"@types/convict": "^4.2.1", "@types/convict": "^4.2.1",
@ -59,6 +60,7 @@
"typescript": "~3.5.2" "typescript": "~3.5.2"
}, },
"dependencies": { "dependencies": {
"basic-auth": "^2.0.1",
"body-parser": "^1.18.3", "body-parser": "^1.18.3",
"compression": "^1.7.4", "compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.6.0",

View file

@ -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 export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');

View file

@ -61,11 +61,12 @@ import {
Not, Not,
} from 'typeorm'; } from 'typeorm';
import * as parseUrl from 'parseurl'; import * as basicAuth from 'basic-auth';
import * as compression from 'compression';
import * as config from '../config'; import * as config from '../config';
// @ts-ignore // @ts-ignore
import * as timezones from 'google-timezones-json'; import * as timezones from 'google-timezones-json';
import * as compression from 'compression'; import * as parseUrl from 'parseurl';
class App { class App {
@ -92,7 +93,6 @@ class App {
this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean; this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean;
this.timezone = config.get('generic.timezone') as string; this.timezone = config.get('generic.timezone') as string;
this.config();
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
this.testWebhooks = TestWebhooks.getInstance(); this.testWebhooks = TestWebhooks.getInstance();
this.push = Push.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()); this.app.use(compression());
// Get push connections // Get push connections
@ -1036,12 +1072,14 @@ class App {
} }
export function start() { export async function start(): Promise<void> {
const PORT = config.get('port'); 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); console.log('n8n ready on port ' + PORT);
}); });
} }