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.
218 lines
6.0 KiB
JavaScript
218 lines
6.0 KiB
JavaScript
/*!
|
|
* 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,
|
|
};
|
|
},
|
|
};
|