Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create TOTP node #5901

Merged
merged 10 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/editor-ui/src/plugins/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
faFileImport,
faFilePdf,
faFilter,
faFingerprint,
faFlask,
faFolderOpen,
faFont,
Expand Down Expand Up @@ -188,6 +189,7 @@ addIcon(faFileExport);
addIcon(faFileImport);
addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFingerprint);
addIcon(faFlask);
addIcon(faFolderOpen);
addIcon(faFont);
Expand Down
33 changes: 33 additions & 0 deletions packages/nodes-base/credentials/TotpApi.credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';

export class TotpApi implements ICredentialType {
name = 'totpApi';

displayName = 'TOTP API';

documentationUrl = 'totp';

properties: INodeProperties[] = [
{
displayName: 'Secret',
name: 'secret',
type: 'string',
typeOptions: { password: true },
default: '',
placeholder: 'e.g. BVDRSBXQB2ZEL5HE',
required: true,
description:
'Secret key encoded in the QR code during setup. <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format#secret">Learn more</a>.',
},
{
displayName: 'Label',
name: 'label',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. GitHub:john-doe',
description:
'Identifier for the TOTP account, in the <code>issuer:username</code> format. <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format#label">Learn more</a>.',
},
];
}
10 changes: 10 additions & 0 deletions packages/nodes-base/nodes/Totp/Totp.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"node": "n8n-nodes-base.totp",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Core Nodes"],
"subcategories": ["Helpers"],
"details": "Generate a time-based one-time password",
"alias": ["2FA", "MFA", "authentication", "Security", "OTP", "password", "multi", "factor"],
"resources": {}
}
168 changes: 168 additions & 0 deletions packages/nodes-base/nodes/Totp/Totp.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
NodeOperationError,
} from 'n8n-workflow';

import OTPAuth from 'otpauth';

export class Totp implements INodeType {
description: INodeTypeDescription = {
displayName: 'TOTP',
name: 'totp',
icon: 'fa:fingerprint',
group: ['transform'],
version: 1,
subtitle: '={{ $parameter["operation"] }}',
description: 'Generate a time-based one-time password',
defaults: {
name: 'TOTP',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'totpApi',
required: true,
},
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Generate Secret',
value: 'generateSecret',
action: 'Generate secret',
},
],
default: 'generateSecret',
},
{
displayName: 'Additional Options',
ivov marked this conversation as resolved.
Show resolved Hide resolved
name: 'additionalOptions',
type: 'collection',
displayOptions: {
show: {
operation: ['generateSecret'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Algorithm',
name: 'algorithm',
type: 'options',
default: 'SHA1',
description: 'HMAC hashing algorithm. Defaults to SHA1.',
options: [
{
name: 'SHA1',
value: 'SHA1',
},
{
name: 'SHA224',
value: 'SHA224',
},
{
name: 'SHA256',
value: 'SHA256',
},
{
name: 'SHA3-224',
value: 'SHA3-224',
},
{
name: 'SHA3-256',
value: 'SHA3-256',
},
{
name: 'SHA3-384',
value: 'SHA3-384',
},
{
name: 'SHA3-512',
value: 'SHA3-512',
},
{
name: 'SHA384',
value: 'SHA384',
},
{
name: 'SHA512',
value: 'SHA512',
},
],
},
{
displayName: 'Digits',
name: 'digits',
type: 'number',
default: 6,
description: 'Number of digits in the generated TOTP code. Defaults to 6 digits.',
},
{
displayName: 'Period',
name: 'period',
type: 'number',
default: 30,
description:
'How many seconds the generated TOTP code is valid for. Defaults to 30 seconds.',
},
],
},
],
};

async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];

const operation = this.getNodeParameter('operation', 0);
const credentials = (await this.getCredentials('totpApi')) as { label: string; secret: string };

if (!credentials.label.includes(':')) {
throw new NodeOperationError(this.getNode(), 'Malformed label - expected `issuer:username`');
}

const additionalOptions = this.getNodeParameter('additionalOptions', 0) as {
algorithm: string;
digits: number;
period: number;
};
ivov marked this conversation as resolved.
Show resolved Hide resolved

const [issuer] = credentials.label.split(':');

const totp = new OTPAuth.TOTP({
issuer,
label: credentials.label,
secret: credentials.secret,
algorithm: additionalOptions.algorithm,
digits: additionalOptions.digits,
period: additionalOptions.period,
});

const token = totp.generate();

const secondsRemaining = (30 * (1 - ((Date.now() / 1000 / 30) % 1))) | 0;
ivov marked this conversation as resolved.
Show resolved Hide resolved

for (let i = 0; i < items.length; i++) {
if (operation === 'generateSecret') {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ token, secondsRemaining }),
{ itemData: { item: i } },
);

returnData.push(...executionData);
}
}

return this.prepareOutputData(returnData);
}
}
46 changes: 46 additions & 0 deletions packages/nodes-base/nodes/Totp/test/Totp.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as Helpers from '../../../test/nodes/Helpers';
import { executeWorkflow } from '../../../test/nodes/ExecuteWorkflow';
import type { WorkflowTestData } from '../../../test/nodes/types';

jest.mock('otpauth', () => {
return {
TOTP: jest.fn().mockImplementation(() => {
return {
generate: jest.fn().mockReturnValue('123456'),
};
}),
};
});

describe('Execute TOTP node', () => {
const tests: WorkflowTestData[] = [
{
description: 'Generate TOTP Token',
input: {
workflowData: Helpers.readJsonFileSync('nodes/Totp/test/Totp.workflow.test.json'),
},
output: {
nodeData: {
TOTP: [[{ json: { token: '123456' } }]], // ignore secondsRemaining to prevent flakiness
},
},
},
];

const nodeTypes = Helpers.setup(tests);

for (const testData of tests) {
test(testData.description, async () => {
const { result } = await executeWorkflow(testData, nodeTypes);

Helpers.getResultNodeData(result, testData).forEach(({ nodeName, resultData }) => {
const expected = testData.output.nodeData[nodeName][0][0].json;
const actual = resultData[0]?.[0].json;

expect(actual?.token).toEqual(expected.token);
});

expect(result.finished).toEqual(true);
});
}
});
41 changes: 41 additions & 0 deletions packages/nodes-base/nodes/Totp/test/Totp.workflow.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"nodes": [
{
"parameters": {},
"id": "f2e03169-0e94-4a42-821b-3e8f67f449d7",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [580, 320]
},
{
"parameters": {
"additionalOptions": {}
},
"id": "831f657d-2724-4a25-bb94-cf37355654bb",
"name": "TOTP",
"type": "n8n-nodes-base.totp",
"typeVersion": 1,
"position": [800, 320],
"credentials": {
"totpApi": {
"id": "1",
"name": "TOTP account"
}
}
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "TOTP",
"type": "main",
"index": 0
}
]
]
}
}
}
5 changes: 4 additions & 1 deletion packages/nodes-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"lint": "eslint --quiet nodes credentials",
"lintfix": "eslint nodes credentials --fix",
"watch": "tsc-watch -p tsconfig.build.json --onSuccess \"pnpm n8n-generate-ui-types\"",
"test": "jest"
"test": "jest packages/nodes-base/nodes/Totp/test/Totp.node.test.ts"
},
"files": [
"dist"
Expand Down Expand Up @@ -305,6 +305,7 @@
"dist/credentials/TodoistApi.credentials.js",
"dist/credentials/TodoistOAuth2Api.credentials.js",
"dist/credentials/TogglApi.credentials.js",
"dist/credentials/TotpApi.credentials.js",
"dist/credentials/TravisCiApi.credentials.js",
"dist/credentials/TrelloApi.credentials.js",
"dist/credentials/TwakeCloudApi.credentials.js",
Expand Down Expand Up @@ -687,6 +688,7 @@
"dist/nodes/TimescaleDb/TimescaleDb.node.js",
"dist/nodes/Todoist/Todoist.node.js",
"dist/nodes/Toggl/TogglTrigger.node.js",
"dist/nodes/Totp/Totp.node.js",
"dist/nodes/TravisCi/TravisCi.node.js",
"dist/nodes/Trello/Trello.node.js",
"dist/nodes/Trello/TrelloTrigger.node.js",
Expand Down Expand Up @@ -882,6 +884,7 @@
"node-html-markdown": "^1.1.3",
"node-ssh": "^12.0.0",
"nodemailer": "^6.7.1",
"otpauth": "^9.1.1",
"pdf-parse": "^1.1.1",
"pg": "^8.3.0",
"pg-promise": "^10.5.8",
Expand Down
22 changes: 22 additions & 0 deletions packages/nodes-base/test/nodes/FakeCredentialsMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IDataObject } from 'n8n-workflow';

// If your test needs data from credentials, you can add it here.
// as JSON.stringify({ id: 'credentials_ID', name: 'credentials_name' }) for specific credentials
// or as 'credentials_type' for all credentials of that type
// expected keys for credentials can be found in packages/nodes-base/credentials/[credentials_type].credentials.ts
export const FAKE_CREDENTIALS_DATA: IDataObject = {
[JSON.stringify({ id: '20', name: 'Airtable account' })]: {
apiKey: 'key456',
},
airtableApi: {
apiKey: 'key123',
},
n8nApi: {
apiKey: 'key123',
baseUrl: 'https://test.app.n8n.cloud/api/v1',
},
totpApi: {
label: 'GitHub:john-doe',
secret: 'BVDRSBXQB2ZEL5HE',
},
};
Loading