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:
Daniel Hiller
2026-08-07 20:11:55 +02:00
parent 36aa732fec
commit 2e4904f77d
74 changed files with 4173 additions and 4 deletions
@@ -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']);
},
};