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:
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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']);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||
|
||||
@@ -38,6 +38,12 @@ module.exports.policies = {
|
||||
'users/update-username': 'is-authenticated',
|
||||
'users/update-avatar': 'is-authenticated',
|
||||
'users/create-api-key': ['is-authenticated', 'is-admin'],
|
||||
'users/setup-totp': 'is-authenticated',
|
||||
'users/enable-totp': 'is-authenticated',
|
||||
'users/disable-totp': 'is-authenticated',
|
||||
'users/regenerate-totp-recovery-codes': 'is-authenticated',
|
||||
'users/index-trusted-devices': 'is-authenticated',
|
||||
'users/delete-trusted-device': 'is-authenticated',
|
||||
'users/delete': ['is-authenticated', 'is-admin'],
|
||||
|
||||
'projects/create': ['is-authenticated', 'is-external', 'is-admin-or-project-owner'],
|
||||
@@ -49,6 +55,7 @@ module.exports.policies = {
|
||||
'bootstrap/show': true,
|
||||
'terms/show': true,
|
||||
'access-tokens/create': true,
|
||||
'access-tokens/verify-totp': true,
|
||||
'access-tokens/accept-terms': true,
|
||||
'access-tokens/revoke-pending-token': true,
|
||||
};
|
||||
|
||||
@@ -117,6 +117,7 @@ module.exports.routes = {
|
||||
'DELETE /api/webhooks/:id': 'webhooks/delete',
|
||||
|
||||
'POST /api/access-tokens': 'access-tokens/create',
|
||||
'POST /api/access-tokens/verify-totp': 'access-tokens/verify-totp',
|
||||
'POST /api/access-tokens/accept-terms': 'access-tokens/accept-terms',
|
||||
'POST /api/access-tokens/revoke-pending-token': 'access-tokens/revoke-pending-token',
|
||||
'DELETE /api/access-tokens/me': 'access-tokens/delete',
|
||||
@@ -130,6 +131,12 @@ module.exports.routes = {
|
||||
'PATCH /api/users/:id/username': 'users/update-username',
|
||||
'POST /api/users/:id/avatar': 'users/update-avatar',
|
||||
'POST /api/users/:id/api-key': 'users/create-api-key',
|
||||
'POST /api/users/:id/totp/setup': 'users/setup-totp',
|
||||
'POST /api/users/:id/totp/enable': 'users/enable-totp',
|
||||
'DELETE /api/users/:id/totp': 'users/disable-totp',
|
||||
'POST /api/users/:id/totp/recovery-codes': 'users/regenerate-totp-recovery-codes',
|
||||
'GET /api/users/:id/trusted-devices': 'users/index-trusted-devices',
|
||||
'DELETE /api/users/:id/trusted-devices/:deviceId': 'users/delete-trusted-device',
|
||||
'DELETE /api/users/:id': 'users/delete',
|
||||
|
||||
'GET /api/projects': 'projects/index',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const AccessTokenSteps = {
|
||||
ACCEPT_TERMS: 'accept-terms',
|
||||
VERIFY_TOTP: 'verify-totp',
|
||||
};
|
||||
|
||||
const POSITION_GAP = 65536;
|
||||
@@ -7,9 +8,14 @@ const POSITION_GAP = 65536;
|
||||
const MAX_SIZE_TO_GET_ENCODING = 8 * 1024 * 1024;
|
||||
const MAX_SIZE_TO_PROCESS_AS_IMAGE = 64 * 1024 * 1024;
|
||||
|
||||
const TRUST_DEVICE_COOKIE_NAME = 'planka-trust-token';
|
||||
const TRUST_DEVICE_EXPIRES_IN_DAYS = 30;
|
||||
|
||||
module.exports = {
|
||||
AccessTokenSteps,
|
||||
POSITION_GAP,
|
||||
MAX_SIZE_TO_GET_ENCODING,
|
||||
MAX_SIZE_TO_PROCESS_AS_IMAGE,
|
||||
TRUST_DEVICE_COOKIE_NAME,
|
||||
TRUST_DEVICE_EXPIRES_IN_DAYS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
module.exports.up = async (knex) => {
|
||||
await knex.schema.alterTable('user_account', (table) => {
|
||||
table.text('totp_secret');
|
||||
table.boolean('is_totp_enabled').notNullable().defaultTo(false);
|
||||
table.timestamp('totp_enabled_at', true);
|
||||
table.jsonb('totp_recovery_codes');
|
||||
});
|
||||
|
||||
return knex.schema.createTable('trusted_device', (table) => {
|
||||
/* Columns */
|
||||
|
||||
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
||||
|
||||
table.bigInteger('user_id').notNullable();
|
||||
|
||||
table.text('token_hash').notNullable();
|
||||
table.text('user_agent_summary');
|
||||
|
||||
table.text('browser_name');
|
||||
table.text('browser_version');
|
||||
table.text('os_name');
|
||||
table.text('os_version');
|
||||
table.text('device_type');
|
||||
table.text('device_vendor');
|
||||
table.text('device_model');
|
||||
table.text('label');
|
||||
|
||||
table.timestamp('expires_at', true).notNullable();
|
||||
table.timestamp('last_used_at', true);
|
||||
|
||||
table.timestamp('created_at', true);
|
||||
table.timestamp('updated_at', true);
|
||||
|
||||
/* Indexes */
|
||||
|
||||
table.index(['user_id', 'expires_at']);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.down = async (knex) => {
|
||||
await knex.schema.dropTable('trusted_device');
|
||||
|
||||
return knex.schema.alterTable('user_account', (table) => {
|
||||
table.dropColumn('totp_secret');
|
||||
table.dropColumn('is_totp_enabled');
|
||||
table.dropColumn('totp_enabled_at');
|
||||
table.dropColumn('totp_recovery_codes');
|
||||
});
|
||||
};
|
||||
Generated
+162
@@ -27,6 +27,7 @@
|
||||
"mime-types": "^3.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"otplib": "^12.0.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"read": "^5.0.1",
|
||||
@@ -37,6 +38,7 @@
|
||||
"sails-postgresql": "^5.0.1",
|
||||
"serve-static": "^2.2.1",
|
||||
"sharp": "^0.35.3",
|
||||
"ua-parser-js": "^2.0.10",
|
||||
"undici": "^7.24.0",
|
||||
"uuid": "^11.1.1",
|
||||
"validator": "^13.15.26",
|
||||
@@ -1241,6 +1243,56 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -2947,6 +2999,26 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-europe-js": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz",
|
||||
"integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -5372,6 +5444,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-standalone-pwa": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz",
|
||||
"integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -6971,6 +7063,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||
@@ -9969,6 +10072,14 @@
|
||||
"url": "https://bevry.me/fund"
|
||||
}
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tildify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
|
||||
@@ -10223,6 +10334,57 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-is-frozen": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz",
|
||||
"integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.10.tgz",
|
||||
"integrity": "sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"detect-europe-js": "^0.1.2",
|
||||
"is-standalone-pwa": "^0.1.1",
|
||||
"ua-is-frozen": "^0.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"mime-types": "^3.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"otplib": "^12.0.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"read": "^5.0.1",
|
||||
@@ -82,6 +83,7 @@
|
||||
"sails-postgresql": "^5.0.1",
|
||||
"serve-static": "^2.2.1",
|
||||
"sharp": "^0.35.3",
|
||||
"ua-parser-js": "^2.0.10",
|
||||
"undici": "^7.24.0",
|
||||
"uuid": "^11.1.1",
|
||||
"validator": "^13.15.26",
|
||||
|
||||
Reference in New Issue
Block a user