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,85 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/trusted-devices/{deviceId}:
* delete:
* summary: Revoke a specific trusted device
* description: Deletes one trusted-device row, forcing TOTP again on that browser at next login. Only accessible for the user themselves.
* tags:
* - Users
* operationId: deleteUserTrustedDevice
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: path
* name: deviceId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Trusted device revoked
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 404:
* $ref: '#/components/responses/NotFound'
*/
const { idInput } = require('../../../utils/inputs');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
TRUSTED_DEVICE_NOT_FOUND: {
trustedDeviceNotFound: 'Trusted device not found',
},
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
deviceId: {
...idInput,
required: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
trustedDeviceNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
if (inputs.id !== currentUser.id) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const deleted = await TrustedDevice.qm.deleteOneByUserIdAndId(currentUser.id, inputs.deviceId);
if (!deleted) {
throw Errors.TRUSTED_DEVICE_NOT_FOUND;
}
return {
item: sails.helpers.trustedDevices.presentOne(deleted),
};
},
};
@@ -0,0 +1,199 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/totp:
* delete:
* summary: Disable / reset TOTP
* description: Users disable their own TOTP by providing their current password and a valid TOTP or recovery code. Admins resetting another user's TOTP must re-enter their own password (step-up auth); this invalidates all of that user's sessions and trust cookies.
* tags:
* - Users
* operationId: disableUserTotp
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* currentPassword:
* type: string
* maxLength: 256
* code:
* type: string
* maxLength: 16
* responses:
* 200:
* description: TOTP disabled
* content:
* application/json:
* schema:
* type: object
* properties:
* item:
* $ref: '#/components/schemas/User'
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 404:
* $ref: '#/components/responses/NotFound'
*/
const bcrypt = require('bcrypt');
const { idInput } = require('../../../utils/inputs');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
INVALID_CURRENT_PASSWORD: {
invalidCurrentPassword: 'Invalid current password',
},
INVALID_TOTP_CODE: {
invalidTotpCode: 'Invalid TOTP code',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
const verifyOwnerCredentials = async (user, inputs) => {
if (!inputs.currentPassword) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
if (!isPasswordValid) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
if (!inputs.code) {
throw Errors.INVALID_TOTP_CODE;
}
if (user.totpSecret) {
const isCodeValid = sails.helpers.utils.verifyTotpCode.with({
code: inputs.code,
secret: user.totpSecret,
});
if (isCodeValid) return;
}
const recoveryCodes = user.totpRecoveryCodes || [];
// eslint-disable-next-line no-restricted-syntax
for (const hashed of recoveryCodes) {
// eslint-disable-next-line no-await-in-loop
if (await bcrypt.compare(inputs.code, hashed)) {
return;
}
}
throw Errors.INVALID_TOTP_CODE;
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
currentPassword: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
},
code: {
type: 'string',
isNotEmptyString: true,
maxLength: 16,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
invalidCurrentPassword: {
responseType: 'forbidden',
},
invalidTotpCode: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentSession, currentUser } = this.req;
const isSelf = inputs.id === currentUser.id;
const isAdmin = currentUser.role === User.Roles.ADMIN;
if (!isSelf && !isAdmin) {
throw Errors.USER_NOT_FOUND; // Forbidden
}
const user = await User.qm.getOneById(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
if (sails.config.custom.demoMode) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (isSelf) {
await verifyOwnerCredentials(user, inputs);
} else {
// Admin path: step-up by re-entering the admin's own password.
// Prevents a hijacked admin session from silently stripping 2FA off other users.
if (!inputs.currentPassword) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
if (!currentUser.password) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
const isAdminPasswordValid = await bcrypt.compare(
inputs.currentPassword,
currentUser.password,
);
if (!isAdminPasswordValid) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
}
const { user: updatedUser } = await User.qm.updateOne(user.id, {
totpSecret: null,
isTotpEnabled: false,
totpEnabledAt: null,
totpRecoveryCodes: null,
});
await sails.helpers.trustedDevices.deleteAllForUser.with({ userId: user.id });
if (!isSelf) {
await sails.helpers.sessions.invalidateAllForUser.with({ userId: user.id });
} else if (currentSession) {
await sails.helpers.sessions.invalidateAllForUser.with({
userId: user.id,
exceptSessionId: currentSession.id,
});
}
return {
item: sails.helpers.users.presentOne(updatedUser, currentUser),
};
},
};
+176
View File
@@ -0,0 +1,176 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/totp/enable:
* post:
* summary: Finalize TOTP enrollment
* description: Verifies the first TOTP code against the pending secret created via /totp/setup, persists the enabled flag, and returns one-time recovery codes.
* tags:
* - Users
* operationId: enableUserTotp
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - currentPassword
* - code
* properties:
* currentPassword:
* type: string
* maxLength: 256
* code:
* type: string
* maxLength: 16
* responses:
* 200:
* description: TOTP enabled
* content:
* application/json:
* schema:
* type: object
* properties:
* item:
* $ref: '#/components/schemas/User'
* included:
* type: object
* properties:
* recoveryCodes:
* type: array
* items:
* type: string
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 404:
* $ref: '#/components/responses/NotFound'
*/
const bcrypt = require('bcrypt');
const { idInput } = require('../../../utils/inputs');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
INVALID_CURRENT_PASSWORD: {
invalidCurrentPassword: 'Invalid current password',
},
INVALID_TOTP_CODE: {
invalidTotpCode: 'Invalid TOTP code',
},
TOTP_SETUP_NOT_INITIATED: {
totpSetupNotInitiated: 'TOTP setup has not been initiated',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
currentPassword: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
required: true,
},
code: {
type: 'string',
isNotEmptyString: true,
maxLength: 16,
required: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
invalidCurrentPassword: {
responseType: 'forbidden',
},
invalidTotpCode: {
responseType: 'forbidden',
},
totpSetupNotInitiated: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
if (inputs.id !== currentUser.id) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const user = await User.qm.getOneById(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
if (sails.config.custom.demoMode) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (!user.password) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
if (!isPasswordValid) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
if (!user.totpSecret) {
throw Errors.TOTP_SETUP_NOT_INITIATED;
}
const isCodeValid = sails.helpers.utils.verifyTotpCode.with({
code: inputs.code,
secret: user.totpSecret,
});
if (!isCodeValid) {
throw Errors.INVALID_TOTP_CODE;
}
const { plain: recoveryCodes, hashed: hashedRecoveryCodes } =
await sails.helpers.utils.generateRecoveryCodes();
const { user: updatedUser } = await User.qm.updateOne(user.id, {
isTotpEnabled: true,
totpEnabledAt: new Date().toISOString(),
totpRecoveryCodes: hashedRecoveryCodes,
});
return {
item: sails.helpers.users.presentOne(updatedUser, currentUser),
included: {
recoveryCodes,
},
};
},
};
@@ -0,0 +1,78 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/trusted-devices:
* get:
* summary: List active trusted devices
* description: Returns the user's currently-valid trusted browsers. Only accessible for the user themselves.
* tags:
* - Users
* operationId: indexUserTrustedDevices
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: List of trusted devices
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 404:
* $ref: '#/components/responses/NotFound'
*/
const { idInput } = require('../../../utils/inputs');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
if (inputs.id !== currentUser.id) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const user = await User.qm.getOneById(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
const devices = await TrustedDevice.qm.getActiveByUserId(user.id);
return {
items: devices.map((d) => sails.helpers.trustedDevices.presentOne(d)),
};
},
};
@@ -0,0 +1,160 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/totp/recovery-codes:
* post:
* summary: Regenerate TOTP recovery codes
* description: Replaces the user's recovery code set with 10 freshly-generated codes. Requires the current password and a valid TOTP code.
* tags:
* - Users
* operationId: regenerateUserTotpRecoveryCodes
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - currentPassword
* - code
* properties:
* currentPassword:
* type: string
* maxLength: 256
* code:
* type: string
* maxLength: 16
* responses:
* 200:
* description: Recovery codes regenerated
* content:
* application/json:
* schema:
* type: object
* properties:
* included:
* type: object
* properties:
* recoveryCodes:
* type: array
* items:
* type: string
*/
const bcrypt = require('bcrypt');
const { idInput } = require('../../../utils/inputs');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
INVALID_CURRENT_PASSWORD: {
invalidCurrentPassword: 'Invalid current password',
},
INVALID_TOTP_CODE: {
invalidTotpCode: 'Invalid TOTP code',
},
TOTP_NOT_ENABLED: {
totpNotEnabled: 'TOTP is not enabled',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
currentPassword: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
required: true,
},
code: {
type: 'string',
isNotEmptyString: true,
maxLength: 16,
required: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
invalidCurrentPassword: {
responseType: 'forbidden',
},
invalidTotpCode: {
responseType: 'forbidden',
},
totpNotEnabled: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
if (inputs.id !== currentUser.id) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const user = await User.qm.getOneById(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
if (!user.isTotpEnabled || !user.totpSecret) {
throw Errors.TOTP_NOT_ENABLED;
}
if (sails.config.custom.demoMode) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
if (!isPasswordValid) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
const isCodeValid = sails.helpers.utils.verifyTotpCode.with({
code: inputs.code,
secret: user.totpSecret,
});
if (!isCodeValid) {
throw Errors.INVALID_TOTP_CODE;
}
const { plain: recoveryCodes, hashed: hashedRecoveryCodes } =
await sails.helpers.utils.generateRecoveryCodes();
await User.qm.updateOne(user.id, {
totpRecoveryCodes: hashedRecoveryCodes,
});
return {
included: {
recoveryCodes,
},
};
},
};
+145
View File
@@ -0,0 +1,145 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/totp/setup:
* post:
* summary: Begin TOTP enrollment
* description: Generates a TOTP secret and provisioning URI for the authenticated user. The user is only fully enrolled after a successful call to /totp/enable.
* tags:
* - Users
* operationId: setupUserTotp
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - currentPassword
* properties:
* currentPassword:
* type: string
* maxLength: 256
* responses:
* 200:
* description: TOTP setup initiated
* content:
* application/json:
* schema:
* type: object
* properties:
* item:
* type: object
* properties:
* secret:
* type: string
* provisioningUri:
* type: string
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 404:
* $ref: '#/components/responses/NotFound'
*/
const bcrypt = require('bcrypt');
const { idInput } = require('../../../utils/inputs');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
INVALID_CURRENT_PASSWORD: {
invalidCurrentPassword: 'Invalid current password',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
currentPassword: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
required: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
invalidCurrentPassword: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
if (inputs.id !== currentUser.id) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const user = await User.qm.getOneById(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
if (sails.config.custom.demoMode) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (!user.password) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
if (!isPasswordValid) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
const secret = sails.helpers.utils.generateTotpSecret();
const provisioningUri = sails.helpers.utils.buildTotpUri.with({
account: user.email,
secret,
issuer: 'Planka',
});
// Only write the pending secret. Leave isTotpEnabled / totpEnabledAt /
// totpRecoveryCodes alone — they only change in enable-totp / disable-totp.
// Otherwise calling setup-totp while TOTP is already enabled would silently
// disable 2FA on the server side (password alone could turn off the second factor).
await User.qm.updateOne(user.id, {
totpSecret: secret,
});
return {
item: {
secret,
provisioningUri,
},
};
},
};