From 2e4904f77db98fdc804c56cd92686e35cdda0f66 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Fri, 7 Aug 2026 20:11:55 +0200 Subject: [PATCH] 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. --- client/package-lock.json | 10 + client/package.json | 1 + client/src/actions/login.js | 40 ++++ client/src/actions/users.js | 161 +++++++++++++ client/src/api/access-tokens.js | 4 + client/src/api/users.js | 22 ++ .../UsersPane/ActionsStep.jsx | 16 ++ .../UsersPane/ResetTotpStep.jsx | 131 +++++++++++ .../UsersPane/ResetTotpStep.module.scss | 23 ++ .../src/components/common/Login/Content.jsx | 2 + .../common/Login/TotpChallengeModal.jsx | 124 ++++++++++ .../Login/TotpChallengeModal.module.scss | 18 ++ .../SecurityPane/RecoveryCodesView.jsx | 74 ++++++ .../RecoveryCodesView.module.scss | 33 +++ .../SecurityPane/SecurityPane.jsx | 124 ++++++++++ .../SecurityPane/SecurityPane.module.scss | 32 +++ .../SecurityPane/TotpDisableModal.jsx | 99 ++++++++ .../SecurityPane/TotpRecoveryCodesModal.jsx | 114 +++++++++ .../SecurityPane/TotpSetupWizard.jsx | 210 +++++++++++++++++ .../SecurityPane/TotpSetupWizard.module.scss | 59 +++++ .../SecurityPane/TrustedDevicesSection.jsx | 142 ++++++++++++ .../TrustedDevicesSection.module.scss | 45 ++++ .../UserSettingsModal/SecurityPane/index.js | 8 + .../UserSettingsModal/UserSettingsModal.jsx | 7 + client/src/constants/AccessTokenSteps.js | 1 + client/src/constants/ActionTypes.js | 26 +++ client/src/constants/EntryActionTypes.js | 11 + client/src/entry-actions/login.js | 14 ++ client/src/entry-actions/users.js | 67 ++++++ client/src/locales/de-DE/core.js | 54 +++++ client/src/locales/de-DE/login.js | 7 + client/src/locales/en-US/core.js | 53 +++++ client/src/locales/en-US/login.js | 7 + client/src/models/User.js | 171 ++++++++++++++ client/src/reducers/auth.js | 1 + client/src/reducers/common.js | 1 + client/src/reducers/ui/authenticate-form.js | 47 ++++ client/src/reducers/ui/index.js | 2 + .../reducers/ui/user-trusted-devices-state.js | 59 +++++ client/src/sagas/core/services/users.js | 144 ++++++++++++ client/src/sagas/core/watchers/users.js | 28 +++ client/src/sagas/login/index.js | 6 +- client/src/sagas/login/services/login.js | 39 ++++ client/src/sagas/login/watchers/login.js | 2 + client/src/selectors/common.js | 4 + .../api/controllers/access-tokens/create.js | 7 + .../controllers/access-tokens/verify-totp.js | 217 ++++++++++++++++++ .../users/delete-trusted-device.js | 85 +++++++ server/api/controllers/users/disable-totp.js | 199 ++++++++++++++++ server/api/controllers/users/enable-totp.js | 176 ++++++++++++++ .../users/index-trusted-devices.js | 78 +++++++ .../users/regenerate-totp-recovery-codes.js | 160 +++++++++++++ server/api/controllers/users/setup-totp.js | 145 ++++++++++++ .../api/helpers/access-tokens/handle-steps.js | 50 +++- .../sessions/invalidate-all-for-user.js | 44 ++++ .../helpers/trusted-devices/check-token.js | 45 ++++ .../api/helpers/trusted-devices/create-one.js | 58 +++++ .../trusted-devices/delete-all-for-user.js | 17 ++ .../helpers/trusted-devices/present-one.js | 19 ++ server/api/helpers/users/present-one.js | 11 +- server/api/helpers/utils/build-totp-uri.js | 28 +++ .../helpers/utils/generate-recovery-codes.js | 56 +++++ .../api/helpers/utils/generate-totp-secret.js | 16 ++ server/api/helpers/utils/parse-user-agent.js | 61 +++++ server/api/helpers/utils/verify-totp-code.js | 38 +++ .../query-methods/models/TrustedDevice.js | 32 +++ server/api/models/TrustedDevice.js | 104 +++++++++ server/api/models/User.js | 50 +++- server/config/policies.js | 7 + server/config/routes.js | 7 + server/constants.js | 6 + ...807130000_add_two_factor_authentication.js | 54 +++++ server/package-lock.json | 162 +++++++++++++ server/package.json | 2 + 74 files changed, 4173 insertions(+), 4 deletions(-) create mode 100644 client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.jsx create mode 100644 client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.module.scss create mode 100644 client/src/components/common/Login/TotpChallengeModal.jsx create mode 100644 client/src/components/common/Login/TotpChallengeModal.module.scss create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/RecoveryCodesView.jsx create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/RecoveryCodesView.module.scss create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/SecurityPane.jsx create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/SecurityPane.module.scss create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/TotpDisableModal.jsx create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/TotpRecoveryCodesModal.jsx create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/TotpSetupWizard.jsx create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/TotpSetupWizard.module.scss create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/TrustedDevicesSection.jsx create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/TrustedDevicesSection.module.scss create mode 100644 client/src/components/users/UserSettingsModal/SecurityPane/index.js create mode 100644 client/src/reducers/ui/user-trusted-devices-state.js create mode 100644 server/api/controllers/access-tokens/verify-totp.js create mode 100644 server/api/controllers/users/delete-trusted-device.js create mode 100644 server/api/controllers/users/disable-totp.js create mode 100644 server/api/controllers/users/enable-totp.js create mode 100644 server/api/controllers/users/index-trusted-devices.js create mode 100644 server/api/controllers/users/regenerate-totp-recovery-codes.js create mode 100644 server/api/controllers/users/setup-totp.js create mode 100644 server/api/helpers/sessions/invalidate-all-for-user.js create mode 100644 server/api/helpers/trusted-devices/check-token.js create mode 100644 server/api/helpers/trusted-devices/create-one.js create mode 100644 server/api/helpers/trusted-devices/delete-all-for-user.js create mode 100644 server/api/helpers/trusted-devices/present-one.js create mode 100644 server/api/helpers/utils/build-totp-uri.js create mode 100644 server/api/helpers/utils/generate-recovery-codes.js create mode 100644 server/api/helpers/utils/generate-totp-secret.js create mode 100644 server/api/helpers/utils/parse-user-agent.js create mode 100644 server/api/helpers/utils/verify-totp-code.js create mode 100644 server/api/hooks/query-methods/models/TrustedDevice.js create mode 100644 server/api/models/TrustedDevice.js create mode 100644 server/db/migrations/20260807130000_add_two_factor_authentication.js diff --git a/client/package-lock.json b/client/package-lock.json index 0cb08bdc..951c6bcb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -58,6 +58,7 @@ "patch-package": "^8.0.1", "photoswipe": "^5.4.4", "prop-types": "^15.8.1", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-datepicker": "^9.1.0", @@ -14345,6 +14346,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", diff --git a/client/package.json b/client/package.json index bdbc1685..ce5746da 100644 --- a/client/package.json +++ b/client/package.json @@ -138,6 +138,7 @@ "patch-package": "^8.0.1", "photoswipe": "^5.4.4", "prop-types": "^15.8.1", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-datepicker": "^9.1.0", diff --git a/client/src/actions/login.js b/client/src/actions/login.js index dee8c258..cd533faf 100644 --- a/client/src/actions/login.js +++ b/client/src/actions/login.js @@ -98,6 +98,44 @@ updateTermsLanguage.failure = (error) => ({ }, }); +const verifyTotp = (data) => ({ + type: ActionTypes.TOTP_VERIFY, + payload: { + data, + }, +}); + +verifyTotp.success = (accessToken) => ({ + type: ActionTypes.TOTP_VERIFY__SUCCESS, + payload: { + accessToken, + }, +}); + +verifyTotp.failure = (error) => ({ + type: ActionTypes.TOTP_VERIFY__FAILURE, + payload: { + error, + }, +}); + +const cancelTotpChallenge = () => ({ + type: ActionTypes.TOTP_CHALLENGE_CANCEL, + payload: {}, +}); + +cancelTotpChallenge.success = () => ({ + type: ActionTypes.TOTP_CHALLENGE_CANCEL__SUCCESS, + payload: {}, +}); + +cancelTotpChallenge.failure = (error) => ({ + type: ActionTypes.TOTP_CHALLENGE_CANCEL__FAILURE, + payload: { + error, + }, +}); + export default { initializeLogin, authenticate, @@ -105,4 +143,6 @@ export default { acceptTerms, cancelTerms, updateTermsLanguage, + verifyTotp, + cancelTotpChallenge, }; diff --git a/client/src/actions/users.js b/client/src/actions/users.js index 917fd4dd..7eddcf30 100644 --- a/client/src/actions/users.js +++ b/client/src/actions/users.js @@ -294,6 +294,159 @@ const clearUserApiKeyValue = (id) => ({ }, }); +const setupUserTotp = (id) => ({ + type: ActionTypes.USER_TOTP_SETUP, + payload: { + id, + }, +}); + +setupUserTotp.success = (id, setup) => ({ + type: ActionTypes.USER_TOTP_SETUP__SUCCESS, + payload: { + id, + setup, + }, +}); + +setupUserTotp.failure = (id, error) => ({ + type: ActionTypes.USER_TOTP_SETUP__FAILURE, + payload: { + id, + error, + }, +}); + +const clearUserTotpSetupValue = (id) => ({ + type: ActionTypes.USER_TOTP_SETUP_VALUE_CLEAR, + payload: { + id, + }, +}); + +const enableUserTotp = (id) => ({ + type: ActionTypes.USER_TOTP_ENABLE, + payload: { + id, + }, +}); + +enableUserTotp.success = (user, recoveryCodes) => ({ + type: ActionTypes.USER_TOTP_ENABLE__SUCCESS, + payload: { + user, + recoveryCodes, + }, +}); + +enableUserTotp.failure = (id, error) => ({ + type: ActionTypes.USER_TOTP_ENABLE__FAILURE, + payload: { + id, + error, + }, +}); + +const disableUserTotp = (id) => ({ + type: ActionTypes.USER_TOTP_DISABLE, + payload: { + id, + }, +}); + +disableUserTotp.success = (user) => ({ + type: ActionTypes.USER_TOTP_DISABLE__SUCCESS, + payload: { + user, + }, +}); + +disableUserTotp.failure = (id, error) => ({ + type: ActionTypes.USER_TOTP_DISABLE__FAILURE, + payload: { + id, + error, + }, +}); + +const regenerateUserTotpRecoveryCodes = (id) => ({ + type: ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE, + payload: { + id, + }, +}); + +regenerateUserTotpRecoveryCodes.success = (id, recoveryCodes) => ({ + type: ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE__SUCCESS, + payload: { + id, + recoveryCodes, + }, +}); + +regenerateUserTotpRecoveryCodes.failure = (id, error) => ({ + type: ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE__FAILURE, + payload: { + id, + error, + }, +}); + +const clearUserTotpRecoveryCodes = (id) => ({ + type: ActionTypes.USER_TOTP_RECOVERY_CODES_CLEAR, + payload: { + id, + }, +}); + +const fetchUserTrustedDevices = (id) => ({ + type: ActionTypes.USER_TRUSTED_DEVICES_FETCH, + payload: { + id, + }, +}); + +fetchUserTrustedDevices.success = (id, devices) => ({ + type: ActionTypes.USER_TRUSTED_DEVICES_FETCH__SUCCESS, + payload: { + id, + devices, + }, +}); + +fetchUserTrustedDevices.failure = (id, error) => ({ + type: ActionTypes.USER_TRUSTED_DEVICES_FETCH__FAILURE, + payload: { + id, + error, + }, +}); + +const deleteUserTrustedDevice = (id, deviceId) => ({ + type: ActionTypes.USER_TRUSTED_DEVICE_DELETE, + payload: { + id, + deviceId, + }, +}); + +deleteUserTrustedDevice.success = (id, device) => ({ + type: ActionTypes.USER_TRUSTED_DEVICE_DELETE__SUCCESS, + payload: { + id, + device, + }, +}); + +deleteUserTrustedDevice.failure = (id, deviceId, error) => ({ + type: ActionTypes.USER_TRUSTED_DEVICE_DELETE__FAILURE, + payload: { + id, + deviceId, + error, + }, +}); + const deleteUser = (id) => ({ type: ActionTypes.USER_DELETE, payload: { @@ -422,6 +575,14 @@ export default { createUserApiKey, deleteUserApiKey, clearUserApiKeyValue, + setupUserTotp, + clearUserTotpSetupValue, + enableUserTotp, + disableUserTotp, + regenerateUserTotpRecoveryCodes, + clearUserTotpRecoveryCodes, + fetchUserTrustedDevices, + deleteUserTrustedDevice, deleteUser, handleUserDelete, addUserToCard, diff --git a/client/src/api/access-tokens.js b/client/src/api/access-tokens.js index 8d770db2..b12691a1 100755 --- a/client/src/api/access-tokens.js +++ b/client/src/api/access-tokens.js @@ -10,6 +10,9 @@ import http from './http'; const createAccessToken = (data, headers) => http.post('/access-tokens?withHttpOnlyToken=true', data, headers); +const verifyTotp = (data, headers) => + http.post('/access-tokens/verify-totp?withHttpOnlyToken=true', data, headers); + // TODO: rename? const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers); @@ -20,6 +23,7 @@ const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', u export default { createAccessToken, + verifyTotp, acceptTerms, revokePendingToken, deleteCurrentAccessToken, diff --git a/client/src/api/users.js b/client/src/api/users.js index 4038a560..b2b386b4 100755 --- a/client/src/api/users.js +++ b/client/src/api/users.js @@ -36,6 +36,22 @@ const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, const createUserApiKey = (userId, headers) => socket.post(`/users/${userId}/api-key`, undefined, headers); +const setupUserTotp = (id, data, headers) => socket.post(`/users/${id}/totp/setup`, data, headers); + +const enableUserTotp = (id, data, headers) => + socket.post(`/users/${id}/totp/enable`, data, headers); + +const disableUserTotp = (id, data, headers) => socket.delete(`/users/${id}/totp`, data, headers); + +const regenerateUserTotpRecoveryCodes = (id, data, headers) => + socket.post(`/users/${id}/totp/recovery-codes`, data, headers); + +const getUserTrustedDevices = (id, headers) => + socket.get(`/users/${id}/trusted-devices`, undefined, headers); + +const deleteUserTrustedDevice = (id, deviceId, headers) => + socket.delete(`/users/${id}/trusted-devices/${deviceId}`, undefined, headers); + const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers); export default { @@ -49,5 +65,11 @@ export default { updateUserUsername, updateUserAvatar, createUserApiKey, + setupUserTotp, + enableUserTotp, + disableUserTotp, + regenerateUserTotpRecoveryCodes, + getUserTrustedDevices, + deleteUserTrustedDevice, deleteUser, }; diff --git a/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx b/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx index 487f19f8..fc20b6ea 100644 --- a/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx +++ b/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx @@ -15,6 +15,7 @@ import entryActions from '../../../../entry-actions'; import { useSteps } from '../../../../hooks'; import SelectRoleStep from './SelectRoleStep'; import ApiKeyStep from './ApiKeyStep'; +import ResetTotpStep from './ResetTotpStep'; import ConfirmationStep from '../../ConfirmationStep'; import EditUserInformationStep from '../../../users/EditUserInformationStep'; import EditUserAvatarStep from '../../../users/EditUserAvatarStep'; @@ -32,6 +33,7 @@ const StepTypes = { EDIT_PASSWORD: 'EDIT_PASSWORD', EDIT_ROLE: 'EDIT_ROLE', API_KEY: 'API_KEY', + RESET_TOTP: 'RESET_TOTP', ACTIVATE: 'ACTIVATE', DEACTIVATE: 'DEACTIVATE', DELETE: 'DELETE', @@ -112,6 +114,10 @@ const ActionsStep = React.memo(({ userId, onClose }) => { openStep(StepTypes.API_KEY); }, [openStep]); + const handleResetTotpClick = useCallback(() => { + openStep(StepTypes.RESET_TOTP); + }, [openStep]); + const handleActivateClick = useCallback(() => { openStep(StepTypes.ACTIVATE); }, [openStep]); @@ -150,6 +156,8 @@ const ActionsStep = React.memo(({ userId, onClose }) => { ); case StepTypes.API_KEY: return ; + case StepTypes.RESET_TOTP: + return ; case StepTypes.ACTIVATE: return ( { context: 'title', })} + {user.isTotpEnabled && !isCurrentUser && ( + + + {t('common.reset2fa', { + context: 'title', + })} + + )} {!isCurrentUser && ( <> { + if (!error) { + return error; + } + + switch (error.message) { + case 'Invalid current password': + return { + type: 'error', + content: 'common.invalidCurrentPassword', + }; + default: + return { + type: 'warning', + content: 'common.unknownError', + }; + } +}; + +const ResetTotpStep = React.memo(({ userId, onBack, onClose }) => { + const selectUserById = useMemo(() => selectors.makeSelectUserById(), []); + const user = useSelector((state) => selectUserById(state, userId)); + + const dispatch = useDispatch(); + const [t] = useTranslation(); + + const { isDisabling, error } = user.totpState || {}; + const wasDisablingRef = useRef(false); + + const [data, handleFieldChange] = useForm({ + currentPassword: '', + }); + + const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef'); + + const message = useMemo(() => createMessage(error), [error]); + + // The admin confirms with their own password, so the only signal that the + // reset went through is the request finishing without an error. + useEffect(() => { + if (wasDisablingRef.current && !isDisabling && !error) { + onClose(); + } + + wasDisablingRef.current = isDisabling; + }, [isDisabling, error, onClose]); + + const handleSubmit = useCallback(() => { + if (!data.currentPassword) { + currentPasswordFieldRef.current.focus(); + return; + } + + dispatch( + entryActions.disableUserTotp(userId, { + currentPassword: data.currentPassword, + }), + ); + }, [dispatch, userId, data.currentPassword, currentPasswordFieldRef]); + + return ( + <> + + {t('common.reset2fa', { + context: 'title', + })} + + +

{t('common.reset2faWarning')}

+ {message && ( + + )} +
+
{t('common.currentPassword')}
+ +