Skip to content

Commit

Permalink
web/satellite: require MFA code to generate MFA recovery codes
Browse files Browse the repository at this point in the history
This change uses the code protected MFA code generation endpoint. It
requires a code from the user before generating new recovery codes.

Issue: storj/storj-private#433

Change-Id: I248649567a4800374b84ee512a79195ea2c44652
  • Loading branch information
wilfred-asomanii committed Sep 18, 2023
1 parent 8ad0bc5 commit f42548a
Show file tree
Hide file tree
Showing 8 changed files with 210 additions and 59 deletions.
30 changes: 30 additions & 0 deletions web/satellite/src/api/auth.ts
Expand Up @@ -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.
*
Expand Down
16 changes: 1 addition & 15 deletions web/satellite/src/components/account/NewSettingsArea.vue
Expand Up @@ -64,7 +64,7 @@
width="208px"
label="Regenerate Recovery Codes"
is-white
:on-press="generateNewMFARecoveryCodes"
:on-press="toggleMFACodesModal"
:is-disabled="isLoading"
/>
<VButton
Expand Down Expand Up @@ -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.
*/
Expand Down
180 changes: 162 additions & 18 deletions web/satellite/src/components/modals/MFARecoveryCodesModal.vue
Expand Up @@ -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.
Expand All @@ -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">
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions web/satellite/src/store/modules/usersStore.ts
Expand Up @@ -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();

Expand Down Expand Up @@ -109,6 +116,7 @@ export const useUsersStore = defineStore('users', () => {
enableUserMFA,
generateUserMFASecret,
generateUserMFARecoveryCodes,
regenerateUserMFARecoveryCodes,
clear,
login,
setUser,
Expand Down
6 changes: 6 additions & 0 deletions web/satellite/src/types/users.ts
Expand Up @@ -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[]>;
}

/**
Expand Down
1 change: 1 addition & 0 deletions web/satellite/src/utils/constants/analyticsEventNames.ts
Expand Up @@ -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',
Expand Down
14 changes: 1 addition & 13 deletions web/satellite/src/views/all-dashboard/AllDashboardArea.vue
Expand Up @@ -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" />
Expand Down Expand Up @@ -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.
*/
Expand Down
14 changes: 1 addition & 13 deletions web/satellite/src/views/dashboard/DashboardArea.vue
Expand Up @@ -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" />

Expand Down Expand Up @@ -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.
*/
Expand Down

0 comments on commit f42548a

Please sign in to comment.