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:
@@ -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',
|
||||
},
|
||||
|
||||
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||
|
||||
Reference in New Issue
Block a user