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
+1
View File
@@ -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,
+1
View File
@@ -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,
+2
View File
@@ -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;
}
};