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
+144
View File
@@ -388,6 +388,136 @@ export function* clearUserApiKeyValue(id) {
yield put(actions.clearUserApiKeyValue(id));
}
export function* setupUserTotp(id, data) {
yield put(actions.setupUserTotp(id));
let setup;
try {
({ item: setup } = yield call(request, api.setupUserTotp, id, data));
} catch (error) {
yield put(actions.setupUserTotp.failure(id, error));
return;
}
yield put(actions.setupUserTotp.success(id, setup));
}
export function* setupCurrentUserTotp(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(setupUserTotp, currentUserId, data);
}
export function* clearCurrentUserTotpSetupValue() {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield put(actions.clearUserTotpSetupValue(currentUserId));
}
export function* enableUserTotp(id, data) {
yield put(actions.enableUserTotp(id));
let user;
let recoveryCodes;
try {
({
item: user,
included: { recoveryCodes },
} = yield call(request, api.enableUserTotp, id, data));
} catch (error) {
yield put(actions.enableUserTotp.failure(id, error));
return;
}
yield put(actions.enableUserTotp.success(user, recoveryCodes));
}
export function* enableCurrentUserTotp(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(enableUserTotp, currentUserId, data);
}
export function* disableUserTotp(id, data) {
yield put(actions.disableUserTotp(id));
let user;
try {
({ item: user } = yield call(request, api.disableUserTotp, id, data));
} catch (error) {
yield put(actions.disableUserTotp.failure(id, error));
return;
}
yield put(actions.disableUserTotp.success(user));
}
export function* disableCurrentUserTotp(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(disableUserTotp, currentUserId, data);
}
export function* regenerateUserTotpRecoveryCodes(id, data) {
yield put(actions.regenerateUserTotpRecoveryCodes(id));
let recoveryCodes;
try {
({
included: { recoveryCodes },
} = yield call(request, api.regenerateUserTotpRecoveryCodes, id, data));
} catch (error) {
yield put(actions.regenerateUserTotpRecoveryCodes.failure(id, error));
return;
}
yield put(actions.regenerateUserTotpRecoveryCodes.success(id, recoveryCodes));
}
export function* regenerateCurrentUserTotpRecoveryCodes(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(regenerateUserTotpRecoveryCodes, currentUserId, data);
}
export function* clearCurrentUserTotpRecoveryCodes() {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield put(actions.clearUserTotpRecoveryCodes(currentUserId));
}
export function* fetchUserTrustedDevices(id) {
yield put(actions.fetchUserTrustedDevices(id));
let devices;
try {
({ items: devices } = yield call(request, api.getUserTrustedDevices, id));
} catch (error) {
yield put(actions.fetchUserTrustedDevices.failure(id, error));
return;
}
yield put(actions.fetchUserTrustedDevices.success(id, devices));
}
export function* fetchCurrentUserTrustedDevices() {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(fetchUserTrustedDevices, currentUserId);
}
export function* deleteUserTrustedDevice(id, deviceId) {
yield put(actions.deleteUserTrustedDevice(id, deviceId));
let device;
try {
({ item: device } = yield call(request, api.deleteUserTrustedDevice, id, deviceId));
} catch (error) {
yield put(actions.deleteUserTrustedDevice.failure(id, deviceId, error));
return;
}
yield put(actions.deleteUserTrustedDevice.success(id, device));
}
export function* deleteCurrentUserTrustedDevice(deviceId) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(deleteUserTrustedDevice, currentUserId, deviceId);
}
export function* deleteUser(id) {
yield put(actions.deleteUser(id));
@@ -528,6 +658,20 @@ export default {
createUserApiKey,
deleteUserApiKey,
clearUserApiKeyValue,
setupUserTotp,
setupCurrentUserTotp,
clearCurrentUserTotpSetupValue,
enableUserTotp,
enableCurrentUserTotp,
disableUserTotp,
disableCurrentUserTotp,
regenerateUserTotpRecoveryCodes,
regenerateCurrentUserTotpRecoveryCodes,
clearCurrentUserTotpRecoveryCodes,
fetchUserTrustedDevices,
fetchCurrentUserTrustedDevices,
deleteUserTrustedDevice,
deleteCurrentUserTrustedDevice,
deleteUser,
handleUserDelete,
addUserToCard,
+28
View File
@@ -79,6 +79,34 @@ export default function* usersWatchers() {
takeEvery(EntryActionTypes.USER_API_KEY_VALUE_CLEAR, ({ payload: { id } }) =>
services.clearUserApiKeyValue(id),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_SETUP, ({ payload: { data } }) =>
services.setupCurrentUserTotp(data),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_SETUP_VALUE_CLEAR, () =>
services.clearCurrentUserTotpSetupValue(),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_ENABLE, ({ payload: { data } }) =>
services.enableCurrentUserTotp(data),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_DISABLE, ({ payload: { data } }) =>
services.disableCurrentUserTotp(data),
),
takeEvery(EntryActionTypes.USER_TOTP_DISABLE, ({ payload: { id, data } }) =>
services.disableUserTotp(id, data),
),
takeEvery(
EntryActionTypes.CURRENT_USER_TOTP_RECOVERY_CODES_REGENERATE,
({ payload: { data } }) => services.regenerateCurrentUserTotpRecoveryCodes(data),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_RECOVERY_CODES_CLEAR, () =>
services.clearCurrentUserTotpRecoveryCodes(),
),
takeEvery(EntryActionTypes.CURRENT_USER_TRUSTED_DEVICES_FETCH, () =>
services.fetchCurrentUserTrustedDevices(),
),
takeEvery(EntryActionTypes.CURRENT_USER_TRUSTED_DEVICE_DELETE, ({ payload: { deviceId } }) =>
services.deleteCurrentUserTrustedDevice(deviceId),
),
takeEvery(EntryActionTypes.USER_DELETE, ({ payload: { id } }) => services.deleteUser(id)),
takeEvery(EntryActionTypes.USER_DELETE_HANDLE, ({ payload: { user } }) =>
services.handleUserDelete(user),
+5 -1
View File
@@ -15,7 +15,11 @@ export default function* loginSaga() {
yield fork(services.initializeLogin);
yield take([ActionTypes.AUTHENTICATE__SUCCESS, ActionTypes.TERMS_ACCEPT__SUCCESS]);
yield take([
ActionTypes.AUTHENTICATE__SUCCESS,
ActionTypes.TOTP_VERIFY__SUCCESS,
ActionTypes.TERMS_ACCEPT__SUCCESS,
]);
yield cancel(watcherTasks);
yield call(services.goToRoot);
+39
View File
@@ -94,6 +94,43 @@ export function* updateTermsLanguage(value) {
yield put(actions.updateTermsLanguage.success(terms));
}
export function* verifyTotp(data) {
yield put(actions.verifyTotp(data));
const { pendingToken } = yield select(selectors.selectAuthenticateForm);
let accessToken;
try {
({ item: accessToken } = yield call(api.verifyTotp, {
...data,
pendingToken,
}));
} catch (error) {
yield put(actions.verifyTotp.failure(error));
return;
}
yield call(setAccessToken, accessToken);
yield put(actions.verifyTotp.success(accessToken));
}
export function* cancelTotpChallenge() {
const { pendingToken } = yield select(selectors.selectAuthenticateForm);
yield put(actions.cancelTotpChallenge());
try {
yield call(api.revokePendingToken, {
pendingToken,
});
} catch (error) {
yield put(actions.cancelTotpChallenge.failure(error));
return;
}
yield put(actions.cancelTotpChallenge.success());
}
export default {
initializeLogin,
authenticate,
@@ -101,4 +138,6 @@ export default {
acceptTerms,
cancelTerms,
updateTermsLanguage,
verifyTotp,
cancelTotpChallenge,
};
+2
View File
@@ -14,6 +14,8 @@ export default function* loginWatchers() {
services.authenticate(data),
),
takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()),
takeEvery(EntryActionTypes.TOTP_VERIFY, ({ payload: { data } }) => services.verifyTotp(data)),
takeEvery(EntryActionTypes.TOTP_CHALLENGE_CANCEL, () => services.cancelTotpChallenge()),
takeEvery(EntryActionTypes.TERMS_ACCEPT, ({ payload: { signature } }) =>
services.acceptTerms(signature),
),