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:
@@ -15,6 +15,7 @@ const initialState = {
|
||||
export default (state = initialState, { type, payload }) => {
|
||||
switch (type) {
|
||||
case ActionTypes.AUTHENTICATE__SUCCESS:
|
||||
case ActionTypes.TOTP_VERIFY__SUCCESS:
|
||||
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -33,6 +33,7 @@ export default (state = initialState, { type, payload }) => {
|
||||
bootstrap: payload.bootstrap,
|
||||
};
|
||||
case ActionTypes.AUTHENTICATE__SUCCESS:
|
||||
case ActionTypes.TOTP_VERIFY__SUCCESS:
|
||||
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import ActionTypes from '../../constants/ActionTypes';
|
||||
import AccessTokenSteps from '../../constants/AccessTokenSteps';
|
||||
|
||||
const initialState = {
|
||||
data: {
|
||||
@@ -20,6 +21,11 @@ const initialState = {
|
||||
isCancelling: false,
|
||||
isLanguageUpdating: false,
|
||||
},
|
||||
totpForm: {
|
||||
isSubmitting: false,
|
||||
isCancelling: false,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
@@ -38,6 +44,9 @@ export default (state = initialState, { type, payload }) => {
|
||||
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
||||
case ActionTypes.TERMS_CANCEL__SUCCESS:
|
||||
case ActionTypes.TERMS_CANCEL__FAILURE:
|
||||
case ActionTypes.TOTP_VERIFY__SUCCESS:
|
||||
case ActionTypes.TOTP_CHALLENGE_CANCEL__SUCCESS:
|
||||
case ActionTypes.TOTP_CHALLENGE_CANCEL__FAILURE:
|
||||
return initialState;
|
||||
case ActionTypes.AUTHENTICATE__FAILURE:
|
||||
if (payload.terms) {
|
||||
@@ -53,11 +62,49 @@ export default (state = initialState, { type, payload }) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.error && payload.error.step === AccessTokenSteps.VERIFY_TOTP) {
|
||||
return {
|
||||
...state,
|
||||
data: initialState.data,
|
||||
isSubmitting: false,
|
||||
pendingToken: payload.error.pendingToken,
|
||||
step: payload.error.step,
|
||||
totpForm: initialState.totpForm,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: false,
|
||||
error: payload.error,
|
||||
};
|
||||
case ActionTypes.TOTP_VERIFY:
|
||||
return {
|
||||
...state,
|
||||
totpForm: {
|
||||
...state.totpForm,
|
||||
isSubmitting: true,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
case ActionTypes.TOTP_VERIFY__FAILURE:
|
||||
return {
|
||||
...state,
|
||||
totpForm: {
|
||||
...state.totpForm,
|
||||
isSubmitting: false,
|
||||
error: payload.error,
|
||||
},
|
||||
};
|
||||
case ActionTypes.TOTP_CHALLENGE_CANCEL:
|
||||
return {
|
||||
...state,
|
||||
pendingToken: null,
|
||||
totpForm: {
|
||||
...state.totpForm,
|
||||
isCancelling: true,
|
||||
},
|
||||
};
|
||||
case ActionTypes.AUTHENTICATE_ERROR_CLEAR:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -9,10 +9,12 @@ import authenticateForm from './authenticate-form';
|
||||
import userCreateForm from './user-create-form';
|
||||
import projectCreateForm from './project-create-form';
|
||||
import smtpTestState from './smtp-test-state';
|
||||
import userTrustedDevicesState from './user-trusted-devices-state';
|
||||
|
||||
export default combineReducers({
|
||||
authenticateForm,
|
||||
userCreateForm,
|
||||
projectCreateForm,
|
||||
smtpTestState,
|
||||
userTrustedDevicesState,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import ActionTypes from '../../constants/ActionTypes';
|
||||
|
||||
const initialState = {
|
||||
items: [],
|
||||
isFetching: false,
|
||||
isFetched: false,
|
||||
deletingIds: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export default (state = initialState, { type, payload }) => {
|
||||
switch (type) {
|
||||
case ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE:
|
||||
return initialState;
|
||||
case ActionTypes.USER_TRUSTED_DEVICES_FETCH:
|
||||
return {
|
||||
...state,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
};
|
||||
case ActionTypes.USER_TRUSTED_DEVICES_FETCH__SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
items: payload.devices,
|
||||
};
|
||||
case ActionTypes.USER_TRUSTED_DEVICES_FETCH__FAILURE:
|
||||
return {
|
||||
...state,
|
||||
isFetching: false,
|
||||
error: payload.error,
|
||||
};
|
||||
case ActionTypes.USER_TRUSTED_DEVICE_DELETE:
|
||||
return {
|
||||
...state,
|
||||
deletingIds: [...state.deletingIds, payload.deviceId],
|
||||
};
|
||||
case ActionTypes.USER_TRUSTED_DEVICE_DELETE__SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.filter((d) => d.id !== payload.device.id),
|
||||
deletingIds: state.deletingIds.filter((id) => id !== payload.device.id),
|
||||
};
|
||||
case ActionTypes.USER_TRUSTED_DEVICE_DELETE__FAILURE:
|
||||
return {
|
||||
...state,
|
||||
deletingIds: state.deletingIds.filter((id) => id !== payload.deviceId),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user