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:
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
const { AccessTokenSteps } = require('../../../constants');
|
||||
const { AccessTokenSteps, TRUST_DEVICE_COOKIE_NAME } = require('../../../constants');
|
||||
|
||||
const Errors = {
|
||||
ADMIN_LOGIN_REQUIRED_TO_INITIALIZE_INSTANCE: {
|
||||
@@ -39,6 +39,7 @@ module.exports = {
|
||||
exits: {
|
||||
adminLoginRequiredToInitializeInstance: {},
|
||||
termsAcceptanceRequired: {},
|
||||
totpVerificationRequired: {},
|
||||
},
|
||||
|
||||
async fn(inputs) {
|
||||
@@ -91,6 +92,53 @@ module.exports = {
|
||||
};
|
||||
}
|
||||
|
||||
if (inputs.user.isTotpEnabled) {
|
||||
const trustCookie =
|
||||
inputs.request.cookies && inputs.request.cookies[TRUST_DEVICE_COOKIE_NAME];
|
||||
|
||||
const isDeviceTrusted = trustCookie
|
||||
? await sails.helpers.trustedDevices.checkToken.with({
|
||||
userId: inputs.user.id,
|
||||
plainToken: trustCookie,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!isDeviceTrusted) {
|
||||
const { token: pendingToken, payload: pendingTokenPayload } =
|
||||
sails.helpers.utils.createJwtToken(
|
||||
AccessTokenSteps.VERIFY_TOTP,
|
||||
undefined,
|
||||
PENDING_TOKEN_EXPIRES_IN,
|
||||
);
|
||||
|
||||
const session = await sails.helpers.sessions.createOne.with({
|
||||
values: {
|
||||
pendingToken,
|
||||
userId: inputs.user.id,
|
||||
remoteAddress: inputs.remoteAddress,
|
||||
userAgent: inputs.request.headers['user-agent'],
|
||||
},
|
||||
withHttpOnlyToken: inputs.withHttpOnlyToken,
|
||||
});
|
||||
|
||||
if (session.httpOnlyToken && !inputs.request.isSocket) {
|
||||
sails.helpers.utils.setHttpOnlyTokenCookie(
|
||||
session.httpOnlyToken,
|
||||
pendingTokenPayload,
|
||||
inputs.response,
|
||||
);
|
||||
}
|
||||
|
||||
throw {
|
||||
totpVerificationRequired: {
|
||||
pendingToken,
|
||||
message: 'TOTP verification required',
|
||||
step: AccessTokenSteps.VERIFY_TOTP,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
|
||||
inputs.user.id,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
exceptSessionId: {
|
||||
type: 'string',
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs) {
|
||||
const criteria = {
|
||||
userId: inputs.userId,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (inputs.exceptSessionId) {
|
||||
criteria.id = { '!=': inputs.exceptSessionId };
|
||||
}
|
||||
|
||||
const sessions = await Session.find(criteria);
|
||||
if (sessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Session.update(criteria).set({
|
||||
deletedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
sessions.forEach((session) => {
|
||||
if (session.accessToken) {
|
||||
const roomName = `@accessToken:${session.accessToken}`;
|
||||
sails.sockets.broadcast(roomName, 'logout');
|
||||
sails.sockets.leaveAll(roomName);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*!
|
||||
* 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');
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
plainToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs) {
|
||||
const candidates = await TrustedDevice.qm.getActiveByUserId(inputs.userId);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const candidate of candidates) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const matched = await bcrypt.compare(inputs.plainToken, candidate.tokenHash);
|
||||
if (matched) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await TrustedDevice.qm.updateOne(
|
||||
{ id: candidate.id },
|
||||
{ lastUsedAt: new Date().toISOString() },
|
||||
);
|
||||
} catch (error) {
|
||||
sails.log.warn(
|
||||
`Failed to update lastUsedAt for trusted device ${candidate.id}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*!
|
||||
* 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 { TRUST_DEVICE_EXPIRES_IN_DAYS } = require('../../../constants');
|
||||
|
||||
const TOKEN_BYTES = 32;
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
const buildUserAgentSummary = (userAgent) => {
|
||||
if (!userAgent) return null;
|
||||
return userAgent.length > 200 ? userAgent.slice(0, 200) : userAgent;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
userAgent: {
|
||||
type: 'string',
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs) {
|
||||
const plainToken = crypto.randomBytes(TOKEN_BYTES).toString('base64url');
|
||||
const tokenHash = await bcrypt.hash(plainToken, BCRYPT_ROUNDS);
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + TRUST_DEVICE_EXPIRES_IN_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const fingerprint = sails.helpers.utils.parseUserAgent.with({ userAgent: inputs.userAgent });
|
||||
|
||||
const record = await TrustedDevice.qm.createOne({
|
||||
userId: inputs.userId,
|
||||
tokenHash,
|
||||
userAgentSummary: buildUserAgentSummary(inputs.userAgent),
|
||||
browserName: fingerprint.browserName,
|
||||
browserVersion: fingerprint.browserVersion,
|
||||
osName: fingerprint.osName,
|
||||
osVersion: fingerprint.osVersion,
|
||||
deviceType: fingerprint.deviceType,
|
||||
deviceVendor: fingerprint.deviceVendor,
|
||||
deviceModel: fingerprint.deviceModel,
|
||||
expiresAt,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { record, plainToken };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs) {
|
||||
await TrustedDevice.qm.deleteByUserId(inputs.userId);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
sync: true,
|
||||
|
||||
inputs: {
|
||||
record: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
fn(inputs) {
|
||||
return _.omit(inputs.record, ['tokenHash']);
|
||||
},
|
||||
};
|
||||
@@ -17,6 +17,8 @@ module.exports = {
|
||||
},
|
||||
|
||||
fn(inputs) {
|
||||
const recoveryCodes = inputs.record.totpRecoveryCodes;
|
||||
|
||||
const data = {
|
||||
..._.omit(inputs.record, [
|
||||
'password',
|
||||
@@ -26,6 +28,8 @@ module.exports = {
|
||||
'passwordChangedAt',
|
||||
'apiKeyCreatedAt',
|
||||
'termsAcceptedAt',
|
||||
'totpSecret',
|
||||
'totpRecoveryCodes',
|
||||
]),
|
||||
avatar: inputs.record.avatar && {
|
||||
url: `${sails.config.custom.baseUrl}/user-avatars/${inputs.record.avatar.uploadedFileId}/original.${inputs.record.avatar.extension}`,
|
||||
@@ -34,6 +38,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
language: inputs.record.language || sails.config.i18n.defaultLocale,
|
||||
totpRecoveryCodesRemaining: Array.isArray(recoveryCodes) ? recoveryCodes.length : 0,
|
||||
};
|
||||
|
||||
const gravatarUrl = sails.helpers.users.buildGravatarUrl(inputs.record);
|
||||
@@ -68,7 +73,11 @@ module.exports = {
|
||||
return _.omit(data, User.PERSONAL_FIELD_NAMES);
|
||||
}
|
||||
|
||||
return _.omit(data, [...User.PRIVATE_FIELD_NAMES, ...User.PERSONAL_FIELD_NAMES]);
|
||||
return _.omit(data, [
|
||||
...User.PRIVATE_FIELD_NAMES,
|
||||
...User.PERSONAL_FIELD_NAMES,
|
||||
...User.TWO_FACTOR_VISIBLE_FIELD_NAMES,
|
||||
]);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -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