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
@@ -98,6 +98,7 @@
* type: string
* enum:
* - Terms acceptance required
* - TOTP verification required
* - Admin login required to initialize instance
* description: Specific error message
* example: Terms acceptance required
@@ -155,6 +156,9 @@ module.exports = {
termsAcceptanceRequired: {
responseType: 'forbidden',
},
totpVerificationRequired: {
responseType: 'forbidden',
},
adminLoginRequiredToInitializeInstance: {
responseType: 'forbidden',
},
@@ -197,6 +201,9 @@ module.exports = {
}))
.intercept('termsAcceptanceRequired', (error) => ({
termsAcceptanceRequired: error.raw,
}))
.intercept('totpVerificationRequired', (error) => ({
totpVerificationRequired: error.raw,
}));
},
};
@@ -0,0 +1,217 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /access-tokens/verify-totp:
* post:
* summary: Complete TOTP step of the login flow
* description: Exchanges a pending token plus a valid TOTP or recovery code for a full access token. Optionally remembers the browser for 30 days via a trust cookie.
* tags:
* - Access Tokens
* operationId: verifyTotp
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - pendingToken
* - code
* properties:
* pendingToken:
* type: string
* maxLength: 1024
* code:
* type: string
* maxLength: 16
* trustDevice:
* type: boolean
* responses:
* 200:
* description: TOTP verified, access token returned
* content:
* application/json:
* schema:
* type: object
* required:
* - item
* properties:
* item:
* type: string
* security: []
*/
const bcrypt = require('bcrypt');
const { getRemoteAddress } = require('../../../utils/remote-address');
const {
AccessTokenSteps,
TRUST_DEVICE_COOKIE_NAME,
TRUST_DEVICE_EXPIRES_IN_DAYS,
} = require('../../../constants');
const Errors = {
INVALID_PENDING_TOKEN: {
invalidPendingToken: 'Invalid pending token',
},
INVALID_TOTP_CODE: {
invalidTotpCode: 'Invalid TOTP code',
},
};
const setTrustDeviceCookie = (response, plainToken) => {
response.cookie(TRUST_DEVICE_COOKIE_NAME, plainToken, {
maxAge: TRUST_DEVICE_EXPIRES_IN_DAYS * 24 * 60 * 60 * 1000,
path: sails.config.custom.baseUrlPath || '/',
secure: sails.config.custom.baseUrlSecure,
httpOnly: true,
sameSite: 'strict',
});
};
module.exports = {
inputs: {
pendingToken: {
type: 'string',
maxLength: 1024,
required: true,
},
code: {
type: 'string',
isNotEmptyString: true,
maxLength: 16,
required: true,
},
trustDevice: {
type: 'boolean',
},
},
exits: {
invalidPendingToken: {
responseType: 'unauthorized',
},
invalidTotpCode: {
responseType: 'forbidden',
},
},
async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req);
const { httpOnlyToken } = this.req.cookies;
let payload;
try {
payload = sails.helpers.utils.verifyJwtToken(inputs.pendingToken);
} catch (error) {
if (error.raw && error.raw.name === 'TokenExpiredError') {
throw Errors.INVALID_PENDING_TOKEN;
}
sails.log.warn(`Invalid pending token! (IP: ${remoteAddress})`);
throw Errors.INVALID_PENDING_TOKEN;
}
if (payload.subject !== AccessTokenSteps.VERIFY_TOTP) {
throw Errors.INVALID_PENDING_TOKEN;
}
let session = await Session.qm.getOneUndeletedByPendingToken(inputs.pendingToken);
if (!session) {
sails.log.warn(`Invalid pending token! (IP: ${remoteAddress})`);
throw Errors.INVALID_PENDING_TOKEN;
}
if (session.httpOnlyToken && httpOnlyToken !== session.httpOnlyToken) {
throw Errors.INVALID_PENDING_TOKEN;
}
const user = await User.qm.getOneById(session.userId, {
withDeactivated: false,
});
if (!user || !user.isTotpEnabled || !user.totpSecret) {
throw Errors.INVALID_PENDING_TOKEN;
}
let codeAccepted = sails.helpers.utils.verifyTotpCode.with({
code: inputs.code,
secret: user.totpSecret,
});
let consumedRecoveryIndex = -1;
if (!codeAccepted) {
const recoveryCodes = user.totpRecoveryCodes || [];
for (let i = 0; i < recoveryCodes.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
if (await bcrypt.compare(inputs.code, recoveryCodes[i])) {
codeAccepted = true;
consumedRecoveryIndex = i;
break;
}
}
}
if (!codeAccepted) {
sails.log.warn(`Invalid TOTP code! (IP: ${remoteAddress})`);
throw Errors.INVALID_TOTP_CODE;
}
if (consumedRecoveryIndex >= 0) {
const previousRecoveryCodes = user.totpRecoveryCodes || [];
const remaining = previousRecoveryCodes.filter(
(_value, idx) => idx !== consumedRecoveryIndex,
);
// Compare-and-set: only swap the array if it still matches the state we read.
// A second concurrent verify-totp using the same recovery code will see
// rowCount = 0 here and be rejected as INVALID_TOTP_CODE — preventing replay.
const queryResult = await sails.sendNativeQuery(
'UPDATE user_account SET totp_recovery_codes = $1::jsonb, updated_at = $2 WHERE id = $3 AND totp_recovery_codes = $4::jsonb',
[
JSON.stringify(remaining),
new Date().toISOString(),
user.id,
JSON.stringify(previousRecoveryCodes),
],
);
if (queryResult.rowCount === 0) {
sails.log.warn(`Recovery code race detected, rejecting (IP: ${remoteAddress})`);
throw Errors.INVALID_TOTP_CODE;
}
}
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
user.id,
);
session = await Session.qm.updateOne(session.id, {
accessToken,
pendingToken: null,
});
if (session.httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.setHttpOnlyTokenCookie(
session.httpOnlyToken,
accessTokenPayload,
this.res,
);
}
if (inputs.trustDevice && !this.req.isSocket) {
const { plainToken } = await sails.helpers.trustedDevices.createOne.with({
userId: user.id,
userAgent: this.req.headers['user-agent'] || null,
});
setTrustDeviceCookie(this.res, plainToken);
}
return {
item: accessToken,
};
},
};
@@ -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,
},
};
},
};
@@ -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']);
},
};
+10 -1
View File
@@ -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;
}
},
};
@@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/* Query methods */
const createOne = (values) => TrustedDevice.create({ ...values }).fetch();
const getActiveByUserId = (userId) =>
TrustedDevice.find({
userId,
expiresAt: { '>': new Date().toISOString() },
}).sort('lastUsedAt DESC');
const updateOne = (criteria, values) => TrustedDevice.updateOne(criteria).set({ ...values });
// eslint-disable-next-line no-underscore-dangle
const delete_ = (criteria) => TrustedDevice.destroy(criteria).fetch();
const deleteByUserId = (userId) => TrustedDevice.destroy({ userId }).fetch();
const deleteOneByUserIdAndId = (userId, id) => TrustedDevice.destroyOne({ userId, id });
module.exports = {
createOne,
getActiveByUserId,
updateOne,
delete: delete_,
deleteByUserId,
deleteOneByUserIdAndId,
};
+104
View File
@@ -0,0 +1,104 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* TrustedDevice.js
*
* @description :: Stores per-browser trust tokens that allow TOTP-protected users
* to skip the TOTP step for a limited time period.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
tokenHash: {
type: 'string',
required: true,
columnName: 'token_hash',
},
userAgentSummary: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'user_agent_summary',
},
browserName: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'browser_name',
},
browserVersion: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'browser_version',
},
osName: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'os_name',
},
osVersion: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'os_version',
},
deviceType: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'device_type',
},
deviceVendor: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'device_vendor',
},
deviceModel: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'device_model',
},
label: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
},
expiresAt: {
type: 'ref',
required: true,
columnName: 'expires_at',
},
lastUsedAt: {
type: 'ref',
columnName: 'last_used_at',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
userId: {
model: 'User',
required: true,
columnName: 'user_id',
},
},
tableName: 'trusted_device',
};
+49 -1
View File
@@ -142,6 +142,21 @@
* default: byDefault
* description: Default sort order for projects display (personal field)
* example: byDefault
* isTotpEnabled:
* type: boolean
* default: false
* description: Whether TOTP-based two-factor authentication is enabled (visible only to current user or admin)
* example: false
* totpEnabledAt:
* type: string
* format: date-time
* nullable: true
* description: When TOTP was enabled (visible only to current user or admin)
* example: 2026-05-14T10:00:00.000Z
* totpRecoveryCodesRemaining:
* type: integer
* description: Number of unused recovery codes (visible only to current user or admin)
* example: 10
* isDeactivated:
* type: boolean
* default: false
@@ -233,7 +248,20 @@ const LANGUAGES = [
];
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
const PRIVATE_FIELD_NAMES = ['email', 'apiKeyPrefix', 'apiKeyHash', 'apiKeyCreatedAt'];
const PRIVATE_FIELD_NAMES = [
'email',
'apiKeyPrefix',
'apiKeyHash',
'apiKeyCreatedAt',
'totpSecret',
'totpRecoveryCodes',
];
const TWO_FACTOR_VISIBLE_FIELD_NAMES = [
'isTotpEnabled',
'totpEnabledAt',
'totpRecoveryCodesRemaining',
];
const PERSONAL_FIELD_NAMES = [
'language',
@@ -259,6 +287,7 @@ module.exports = {
LANGUAGES,
PRIVATE_FIELD_NAMES,
PERSONAL_FIELD_NAMES,
TWO_FACTOR_VISIBLE_FIELD_NAMES,
INTERNAL,
attributes: {
@@ -384,6 +413,25 @@ module.exports = {
type: 'ref',
columnName: 'terms_accepted_at',
},
totpSecret: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'totp_secret',
},
isTotpEnabled: {
type: 'boolean',
defaultsTo: false,
columnName: 'is_totp_enabled',
},
totpEnabledAt: {
type: 'ref',
columnName: 'totp_enabled_at',
},
totpRecoveryCodes: {
type: 'json',
columnName: 'totp_recovery_codes',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗