web/satellite: require MFA code to generate MFA recovery codes

This change uses the code protected MFA code generation endpoint. It
requires a code from the user before generating new recovery codes.

Issue: https://github.com/storj/storj-private/issues/433

Change-Id: I248649567a4800374b84ee512a79195ea2c44652
This commit is contained in:
Wilfred Asomani 2023-09-14 13:44:25 +00:00 committed by Wilfred Asomani
parent 8ad0bc5e61
commit f42548ac1c
8 changed files with 210 additions and 59 deletions

View File

@ -483,6 +483,36 @@ export class AuthHttpApi implements UsersApi {
});
}
/**
* Generate user's MFA recovery codes requiring a code.
*
* @throws Error
*/
public async regenerateUserMFARecoveryCodes(passcode?: string, recoveryCode?:string): Promise<string[]> {
if (!passcode && !recoveryCode) {
throw new Error('Either passcode or recovery code should be provided');
}
const path = `${this.ROOT_PATH}/mfa/regenerate-recovery-codes`;
const body = {
passcode: passcode || null,
recoveryCode: recoveryCode || null,
};
const response = await this.http.post(path, JSON.stringify(body));
if (response.ok) {
return await response.json();
}
const result = await response.json();
const errMsg = result.error || 'Cannot regenerate MFA codes. Please try again later';
throw new APIError({
status: response.status,
message: errMsg,
requestID: response.headers.get('x-request-id'),
});
}
/**
* Used to reset user's password.
*

View File

@ -64,7 +64,7 @@
width="208px"
label="Regenerate Recovery Codes"
is-white
:on-press="generateNewMFARecoveryCodes"
:on-press="toggleMFACodesModal"
:is-disabled="isLoading"
/>
<VButton
@ -189,20 +189,6 @@ async function enableMFA(): Promise<void> {
});
}
/**
* Toggles generate new MFA recovery codes popup visibility.
*/
async function generateNewMFARecoveryCodes(): Promise<void> {
await withLoading(async () => {
try {
await usersStore.generateUserMFARecoveryCodes();
toggleMFACodesModal();
} catch (error) {
notify.notifyError(error, AnalyticsErrorEventSource.ACCOUNT_SETTINGS_AREA);
}
});
}
/**
* Lifecycle hook after initial render where user info is fetching.
*/

View File

@ -6,40 +6,90 @@
<template #content>
<div class="recovery">
<h1 class="recovery__title">Two-Factor Authentication</h1>
<div class="recovery__codes">
<p class="recovery__codes__subtitle">
Please save these codes somewhere to be able to recover access to your account.
</p>
<p
v-for="(code, index) in userMFARecoveryCodes"
:key="index"
>
{{ code }}
</p>
<p v-if="isConfirmCode" class="recovery__subtitle">
Enter code from your favorite TOTP app to regenerate 2FA codes.
</p>
<div v-if="isConfirmCode" class="recovery__confirm">
<div class="recovery__confirm">
<h2 class="recovery__confirm__title">Confirm Authentication Code</h2>
<ConfirmMFAInput ref="mfaInput" :on-input="onConfirmInput" :is-error="isError" :is-recovery="isRecoveryCodeState" />
<span class="recovery__confirm__toggle" @click="toggleRecoveryCodeState">
Or use {{ isRecoveryCodeState ? '2FA code' : 'recovery code' }}
</span>
</div>
<div class="recovery__confirm__buttons">
<VButton
label="Cancel"
width="100%"
height="44px"
:is-white="true"
:on-press="closeModal"
/>
<VButton
label="Regenerate"
width="100%"
height="44px"
:on-press="regenerate"
:is-disabled="!confirmPasscode || isLoading"
/>
</div>
</div>
<VButton
class="recovery__done-button"
label="Done"
width="100%"
height="44px"
:on-press="closeModal"
/>
<template v-else>
<div class="recovery__codes">
<p class="recovery__codes__subtitle">
Please save these codes somewhere to be able to recover access to your account.
</p>
<p
v-for="(code, index) in userMFARecoveryCodes"
:key="index"
>
{{ code }}
</p>
</div>
<VButton
class="recovery__done-button"
label="Done"
width="100%"
height="44px"
:on-press="closeModal"
/>
</template>
</div>
</template>
</VModal>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useUsersStore } from '@/store/modules/usersStore';
import { useAppStore } from '@/store/modules/appStore';
import { useLoading } from '@/composables/useLoading';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import VButton from '@/components/common/VButton.vue';
import VModal from '@/components/common/VModal.vue';
import ConfirmMFAInput from '@/components/account/mfa/ConfirmMFAInput.vue';
interface ClearInput {
clearInput(): void;
}
const usersStore = useUsersStore();
const appStore = useAppStore();
const notify = useNotify();
const { withLoading, isLoading } = useLoading();
const isConfirmCode = ref(true);
const confirmPasscode = ref<string>('');
const isError = ref<boolean>(false);
const isRecoveryCodeState = ref<boolean>(false);
const mfaInput = ref<ClearInput>();
/**
* Returns MFA recovery codes from store.
@ -54,6 +104,45 @@ const userMFARecoveryCodes = computed((): string[] => {
function closeModal(): void {
appStore.removeActiveModal();
}
/**
* Sets confirmation passcode value from input.
*/
function onConfirmInput(value: string): void {
isError.value = false;
confirmPasscode.value = value;
}
/**
* Toggles whether the MFA recovery code input is shown.
*/
function toggleRecoveryCodeState(): void {
isError.value = false;
confirmPasscode.value = '';
mfaInput.value?.clearInput();
isRecoveryCodeState.value = !isRecoveryCodeState.value;
}
/**
* Regenerates user MFA codes and sets view to Recovery Codes state.
*/
function regenerate(): void {
if (!confirmPasscode.value || isLoading.value || isError.value) return;
withLoading(async () => {
try {
const code = isRecoveryCodeState.value ? { recoveryCode: confirmPasscode.value } : { passcode: confirmPasscode.value };
await usersStore.regenerateUserMFARecoveryCodes(code);
isConfirmCode.value = false;
confirmPasscode.value = '';
notify.success('MFA codes were regenerated successfully');
} catch (error) {
notify.notifyError(error, AnalyticsErrorEventSource.MFA_CODES_MODAL);
isError.value = true;
}
});
}
</script>
<style scoped lang="scss">
@ -85,6 +174,61 @@ function closeModal(): void {
}
}
&__subtitle {
font-size: 16px;
line-height: 21px;
text-align: center;
color: #000;
margin: 0 0 45px;
@media screen and (width <= 550px) {
font-size: 14px;
line-height: 18px;
margin-bottom: 20px;
}
}
&__confirm {
padding: 25px;
background: #f5f6fa;
border-radius: 6px;
width: calc(100% - 50px);
display: flex;
flex-direction: column;
align-items: center;
&__title {
font-size: 16px;
line-height: 19px;
text-align: center;
color: #000;
margin-bottom: 20px;
}
&__toggle {
font-size: 16px;
color: #0068dc;
cursor: pointer;
margin-top: 20px;
text-align: center;
}
&__buttons {
display: flex;
align-items: center;
width: 100%;
margin-top: 30px;
column-gap: 20px;
@media screen and (width <= 550px) {
flex-direction: column-reverse;
column-gap: unset;
row-gap: 10px;
margin-top: 20px;
}
}
}
&__codes {
padding: 25px;
background: #f5f6fa;

View File

@ -73,6 +73,13 @@ export const useUsersStore = defineStore('users', () => {
state.user.mfaRecoveryCodeCount = codes.length;
}
async function regenerateUserMFARecoveryCodes(code: { recoveryCode?: string, passcode?: string }): Promise<void> {
const codes = await api.regenerateUserMFARecoveryCodes(code.passcode, code.recoveryCode);
state.userMFARecoveryCodes = codes;
state.user.mfaRecoveryCodeCount = codes.length;
}
async function getSettings(): Promise<UserSettings> {
const settings = await api.getUserSettings();
@ -109,6 +116,7 @@ export const useUsersStore = defineStore('users', () => {
enableUserMFA,
generateUserMFASecret,
generateUserMFARecoveryCodes,
regenerateUserMFARecoveryCodes,
clear,
login,
setUser,

View File

@ -70,6 +70,12 @@ export interface UsersApi {
* @throws Error
*/
generateUserMFARecoveryCodes(): Promise<string[]>;
/**
* Generate user's MFA recovery codes requiring a code.
*
* @throws Error
*/
regenerateUserMFARecoveryCodes(passcode?: string, recoveryCode?: string): Promise<string[]>;
}
/**

View File

@ -88,6 +88,7 @@ export enum AnalyticsErrorEventSource {
CREATE_BUCKET_MODAL = 'Create bucket modal',
DELETE_BUCKET_MODAL = 'Delete bucket modal',
ENABLE_MFA_MODAL = 'Enable MFA modal',
MFA_CODES_MODAL = 'MFA codes modal',
DISABLE_MFA_MODAL = 'Disable MFA modal',
EDIT_PROFILE_MODAL = 'Edit profile modal',
CREATE_FOLDER_MODAL = 'Create folder modal',

View File

@ -10,7 +10,7 @@
<SessionWrapper>
<div class="all-dashboard__bars">
<BetaSatBar v-if="isBetaSatellite" />
<MFARecoveryCodeBar v-if="showMFARecoveryCodeBar" :open-generate-modal="generateNewMFARecoveryCodes" />
<MFARecoveryCodeBar v-if="showMFARecoveryCodeBar" :open-generate-modal="toggleMFARecoveryModal" />
</div>
<heading class="all-dashboard__heading" />
@ -99,18 +99,6 @@ const showMFARecoveryCodeBar = computed((): boolean => {
return user.isMFAEnabled && user.mfaRecoveryCodeCount < recoveryCodeWarningThreshold;
});
/**
* Generates new MFA recovery codes and toggles popup visibility.
*/
async function generateNewMFARecoveryCodes(): Promise<void> {
try {
await usersStore.generateUserMFARecoveryCodes();
toggleMFARecoveryModal();
} catch (error) {
notify.notifyError(error, AnalyticsErrorEventSource.ALL_PROJECT_DASHBOARD);
}
}
/**
* Toggles MFA recovery modal visibility.
*/

View File

@ -16,7 +16,7 @@
>
<div ref="dashboardContent" class="dashboard__wrap__main-area__content-wrap__container">
<BetaSatBar v-if="isBetaSatellite" />
<MFARecoveryCodeBar v-if="showMFARecoveryCodeBar" :open-generate-modal="generateNewMFARecoveryCodes" />
<MFARecoveryCodeBar v-if="showMFARecoveryCodeBar" :open-generate-modal="toggleMFARecoveryModal" />
<div class="dashboard__wrap__main-area__content-wrap__container__content banners">
<ProjectInvitationBanner v-if="isProjectInvitationBannerShown" />
@ -359,18 +359,6 @@ function toggleMFARecoveryModal(): void {
appStore.updateActiveModal(MODALS.mfaRecovery);
}
/**
* Generates new MFA recovery codes and toggles popup visibility.
*/
async function generateNewMFARecoveryCodes(): Promise<void> {
try {
await usersStore.generateUserMFARecoveryCodes();
toggleMFARecoveryModal();
} catch (error) {
notify.notifyError(error, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
}
/**
* Opens add payment method modal.
*/