feat: Add two-factor authentication via TOTP
Adds TOTP setup with QR code, login challenge, recovery codes and trusted devices that let a browser skip the second factor for 30 days. Admins can reset another user's second factor by confirming with their own password.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
inputs: {
|
||||
account: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
issuer: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
|
||||
fn(inputs) {
|
||||
return authenticator.keyuri(inputs.account, inputs.issuer || 'Planka', inputs.secret);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const CODE_COUNT = 10;
|
||||
const GROUP_LENGTH = 5;
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
// Largest multiple of CHARS.length that fits in one byte — anything above it
|
||||
// would skew the character distribution via the modulo, so we rejection-sample.
|
||||
const MAX_UNBIASED_BYTE = Math.floor(256 / CHARS.length) * CHARS.length;
|
||||
|
||||
const pickUnbiasedByte = () => {
|
||||
// Loop bounded statistically: ~11% of bytes get rejected, so the expected
|
||||
// number of draws per character is ~1.125.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const [byte] = crypto.randomBytes(1);
|
||||
if (byte < MAX_UNBIASED_BYTE) {
|
||||
return byte;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateCode = () => {
|
||||
let left = '';
|
||||
let right = '';
|
||||
for (let i = 0; i < GROUP_LENGTH; i += 1) {
|
||||
left += CHARS[pickUnbiasedByte() % CHARS.length];
|
||||
right += CHARS[pickUnbiasedByte() % CHARS.length];
|
||||
}
|
||||
return `${left}-${right}`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {},
|
||||
|
||||
async fn() {
|
||||
const plain = [];
|
||||
const hashed = [];
|
||||
|
||||
for (let i = 0; i < CODE_COUNT; i += 1) {
|
||||
const code = generateCode();
|
||||
plain.push(code);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
hashed.push(await bcrypt.hash(code, BCRYPT_ROUNDS));
|
||||
}
|
||||
|
||||
return { plain, hashed };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
inputs: {},
|
||||
|
||||
fn() {
|
||||
return authenticator.generateSecret();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
const UAParser = require('ua-parser-js');
|
||||
|
||||
const clip = (value, max = 64) => {
|
||||
if (!value) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
return s.length > max ? s.slice(0, max) : s;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
inputs: {
|
||||
userAgent: {
|
||||
type: 'string',
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
|
||||
fn(inputs) {
|
||||
if (!inputs.userAgent) {
|
||||
return {
|
||||
browserName: null,
|
||||
browserVersion: null,
|
||||
osName: null,
|
||||
osVersion: null,
|
||||
deviceType: null,
|
||||
deviceVendor: null,
|
||||
deviceModel: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new UAParser(inputs.userAgent).getResult();
|
||||
return {
|
||||
browserName: clip(parsed.browser.name),
|
||||
browserVersion: clip(parsed.browser.version),
|
||||
osName: clip(parsed.os.name),
|
||||
osVersion: clip(parsed.os.version),
|
||||
deviceType: clip(parsed.device.type) || 'desktop',
|
||||
deviceVendor: clip(parsed.device.vendor),
|
||||
deviceModel: clip(parsed.device.model),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
browserName: null,
|
||||
browserVersion: null,
|
||||
osName: null,
|
||||
osVersion: null,
|
||||
deviceType: null,
|
||||
deviceVendor: null,
|
||||
deviceModel: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
// Isolated instance: setting `authenticator.options = ...` would mutate the
|
||||
// shared singleton and bleed into other callers (e.g. enable-totp's first verify,
|
||||
// where a 90-second-wide acceptance window is too lenient).
|
||||
const verifier = authenticator.clone();
|
||||
verifier.options = { window: 1 };
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
inputs: {
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
fn(inputs) {
|
||||
try {
|
||||
return verifier.verify({
|
||||
token: inputs.code.replace(/\s+/g, ''),
|
||||
secret: inputs.secret,
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user