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
+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',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗