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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user