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')}
+ +