web/satellite: added enabling user MFA functionality to account settings

Added feature flagged functionality for enabling user MFA.
Added new Popup where user will scan qr code and confirm enabling
by entering passcode from MFA app. Also recovery codes will be visible afterwords

Change-Id: Ie8d1bc83c941a08fd8701442601a2d20126c8892
This commit is contained in:
Vitalii Shpital 2021-07-13 15:50:51 +03:00
parent 0d8010e353
commit e463eb17ac
8 changed files with 2192 additions and 1429 deletions

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,9 @@
"graphql": "15.3.0",
"graphql-tag": "2.11.0",
"load-script": "1.0.0",
"otplib": "12.0.1",
"pbkdf2": "3.1.1",
"qrcode": "1.4.4",
"stripe": "8.96.0",
"vue": "2.6.12",
"vue-class-component": "7.2.5",
@ -39,6 +41,7 @@
"@babel/plugin-proposal-object-rest-spread": "7.11.0",
"@types/node": "13.11.1",
"@types/pbkdf2": "3.1.0",
"@types/qrcode": "1.4.1",
"@types/vue2-datepicker": "3.3.0",
"@vue/cli-plugin-babel": "4.5.6",
"@vue/cli-plugin-typescript": "4.5.6",

View File

@ -280,7 +280,7 @@ export class AuthHttpApi {
throw new ErrorUnauthorized();
}
throw new Error('Can not enable MFA. Please try again later');
throw new Error('Can not disable MFA. Please try again later');
}
/**
@ -300,6 +300,6 @@ export class AuthHttpApi {
throw new ErrorUnauthorized();
}
throw new Error('Can not enable MFA. Please try again later');
throw new Error('Can not generate MFA recovery codes. Please try again later');
}
}

View File

@ -21,15 +21,6 @@
:init-value="userInfo.fullName"
@setData="setFullName"
/>
<HeaderedInput
class="full-input"
label="Nickname"
placeholder="Enter Nickname"
width="100%"
ref="shortNameInput"
:init-value="userInfo.shortName"
@setData="setShortName"
/>
<div class="edit-profile-popup__form-container__button-container">
<VButton
label="Cancel"
@ -83,10 +74,6 @@ export default class EditProfilePopup extends Vue {
this.fullNameError = '';
}
public setShortName(value: string): void {
this.userInfo.setShortName(value);
}
/**
* Validates name and tries to update user info and close popup.
*/
@ -107,7 +94,7 @@ export default class EditProfilePopup extends Vue {
await this.$notify.success('Account info successfully updated!');
this.$store.dispatch(APP_STATE_ACTIONS.TOGGLE_EDIT_PROFILE_POPUP);
await this.$store.dispatch(APP_STATE_ACTIONS.TOGGLE_EDIT_PROFILE_POPUP);
}
/**

View File

@ -2,14 +2,14 @@
// See LICENSE for copying information.
<template>
<div class="profile-container">
<h1 class="profile-container__title">Account Settings</h1>
<div class="profile-container__edit-profile no-margin" >
<div class="profile-container__edit-profile__row">
<div class="profile-container__edit-profile__avatar">
<h1 class="profile-container__edit-profile__avatar__letter">{{avatarLetter}}</h1>
<div class="settings">
<h1 class="settings__title">Account Settings</h1>
<div class="settings__edit-profile" >
<div class="settings__edit-profile__row">
<div class="settings__edit-profile__avatar">
<h1 class="settings__edit-profile__avatar__letter">{{avatarLetter}}</h1>
</div>
<div class="profile-container__edit-profile__text">
<div class="settings__edit-profile__text">
<h2 class="profile-bold-text">Edit Profile</h2>
<h3 class="profile-regular-text">This information will be visible to all users</h3>
</div>
@ -19,11 +19,11 @@
@click="toggleEditProfilePopup"
/>
</div>
<div class="profile-container__secondary-container">
<div class="profile-container__secondary-container__change-password">
<div class="profile-container__edit-profile__row">
<ChangePasswordIcon class="profile-container__secondary-container__img"/>
<div class="profile-container__secondary-container__change-password__text-container">
<div class="settings__secondary-container">
<div class="settings__secondary-container__change-password">
<div class="settings__edit-profile__row">
<ChangePasswordIcon class="settings__secondary-container__img"/>
<div class="settings__secondary-container__change-password__text-container">
<h2 class="profile-bold-text">Change Password</h2>
<h3 class="profile-regular-text">6 or more characters</h3>
</div>
@ -33,17 +33,31 @@
@click="toggleChangePasswordPopup"
/>
</div>
<div class="profile-container__secondary-container__email-container">
<div class="profile-container__edit-profile__row">
<EmailIcon class="profile-container__secondary-container__img"/>
<div class="profile-container__secondary-container__email-container__text-container">
<div class="settings__secondary-container__email-container">
<div class="settings__edit-profile__row">
<EmailIcon class="settings__secondary-container__img"/>
<div class="settings__secondary-container__email-container__text-container">
<h2 class="profile-bold-text email">{{user.email}}</h2>
</div>
</div>
</div>
</div>
<div class="settings__mfa" v-if="isMFAEnabled">
<h2 class="profile-bold-text">Two-Factor Authentication</h2>
<p class="profile-regular-text">
To increase your account security, we strongly recommend enabling 2FA on your account.
</p>
<VButton
class="settings__mfa__button"
label="Enable 2FA"
width="173px"
height="44px"
:on-press="toggleEnableMFAModal"
/>
</div>
<ChangePasswordPopup v-if="isChangePasswordPopupShown"/>
<EditProfilePopup v-if="isEditProfilePopupShown"/>
<EnableMFAPopup v-if="isEnableMFAModal" :toggle-modal="toggleEnableMFAModal"/>
</div>
</template>
@ -53,6 +67,7 @@ import { Component, Vue } from 'vue-property-decorator';
import ChangePasswordPopup from '@/components/account/ChangePasswordPopup.vue';
import DeleteAccountPopup from '@/components/account/DeleteAccountPopup.vue';
import EditProfilePopup from '@/components/account/EditProfilePopup.vue';
import EnableMFAPopup from '@/components/account/mfa/EnableMFAPopup.vue';
import VButton from '@/components/common/VButton.vue';
import ChangePasswordIcon from '@/../static/images/account/profile/changePassword.svg';
@ -62,6 +77,7 @@ import EditIcon from '@/../static/images/common/edit.svg';
import { USER_ACTIONS } from '@/store/modules/users';
import { User } from '@/types/users';
import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { MetaUtils } from '@/utils/meta';
@Component({
components: {
@ -72,9 +88,13 @@ import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
DeleteAccountPopup,
ChangePasswordPopup,
EditProfilePopup,
EnableMFAPopup,
},
})
export default class SettingsArea extends Vue {
public isMFAEnabled: boolean = MetaUtils.getMetaContent('mfa-enabled') === 'true';
public isEnableMFAModal = false;
/**
* Lifecycle hook after initial render where user info is fetching.
*/
@ -82,6 +102,13 @@ export default class SettingsArea extends Vue {
this.$store.dispatch(USER_ACTIONS.GET);
}
/**
* Toggles enable MFA modal visibility.
*/
public toggleEnableMFAModal(): void {
this.isEnableMFAModal = !this.isEnableMFAModal;
}
/**
* Opens delete account popup.
*/
@ -124,13 +151,6 @@ export default class SettingsArea extends Vue {
return this.$store.state.appStateModule.appState.isChangePasswordPopupShown;
}
/**
* Indicates if delete account popup is shown.
*/
public get isDeleteAccountPopupShown(): boolean {
return this.$store.state.appStateModule.appState.isDeleteAccountPopupShown;
}
/**
* Returns first letter of user name.
*/
@ -141,10 +161,10 @@ export default class SettingsArea extends Vue {
</script>
<style scoped lang="scss">
.profile-container {
.settings {
position: relative;
font-family: 'font_regular', sans-serif;
padding-bottom: 100px;
padding-bottom: 70px;
&__title {
font-family: 'font_bold', sans-serif;
@ -159,16 +179,13 @@ export default class SettingsArea extends Vue {
width: calc(100% - 80px);
border-radius: 6px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 37px 40px;
margin-top: 40px;
background-color: #fff;
&__row {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
@ -194,7 +211,6 @@ export default class SettingsArea extends Vue {
&__secondary-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-top: 40px;
@ -203,7 +219,6 @@ export default class SettingsArea extends Vue {
height: 66px;
border-radius: 6px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 37px 40px;
@ -219,7 +234,6 @@ export default class SettingsArea extends Vue {
height: 66px;
border-radius: 6px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding: 37px 40px;
@ -236,10 +250,17 @@ export default class SettingsArea extends Vue {
min-height: 60px;
}
}
}
.no-margin {
margin-top: 0;
&__mfa {
margin-top: 40px;
padding: 40px;
border-radius: 6px;
background-color: #fff;
&__button {
margin-top: 20px;
}
}
}
.edit-svg {
@ -265,24 +286,19 @@ export default class SettingsArea extends Vue {
.profile-bold-text {
font-family: 'font_bold', sans-serif;
color: #354049;
margin-block-start: 0.5em;
margin-block-end: 0.5em;
font-size: 18px;
line-height: 27px;
word-break: break-all;
max-height: 80px;
}
.profile-regular-text {
margin-block-start: 0.5em;
margin-block-end: 0.5em;
margin: 10px 0;
color: #afb7c1;
font-size: 16px;
line-height: 21px;
}
.email {
user-select: text;
word-break: break-all;
}
@media screen and (max-width: 1300px) {
@ -308,6 +324,8 @@ export default class SettingsArea extends Vue {
@media screen and (max-height: 825px) {
.profile-container {
height: 535px;
overflow-y: scroll;
&__secondary-container {
margin-top: 20px;
@ -322,46 +340,4 @@ export default class SettingsArea extends Vue {
}
}
}
@media screen and (max-height: 790px) {
.profile-container {
height: 535px;
overflow-y: scroll;
&::-webkit-scrollbar,
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb {
visibility: hidden;
}
}
}
@media screen and (max-height: 770px) {
.profile-container {
height: 515px;
}
}
@media screen and (max-height: 750px) {
.profile-container {
height: 495px;
}
}
@media screen and (max-height: 730px) {
.profile-container {
height: 475px;
}
}
@media screen and (max-height: 710px) {
.profile-container {
height: 455px;
}
}
</style>

View File

@ -0,0 +1,80 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="confirm-mfa">
<label for="confirm-mfa" class="confirm-mfa__label">
<span class="confirm-mfa__label__info">2FA Code</span>
<span class="confirm-mfa__label__error" v-if="isError">Invalid code. Please re-enter.</span>
</label>
<input
id="confirm-mfa"
class="confirm-mfa__input"
placeholder="000000"
type="number"
@input="event => onInput(event.target.value)"
>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class ConfirmMFAInput extends Vue {
@Prop({default: () => false})
public readonly onInput: (value: string) => void;
@Prop({default: false})
public readonly isError: boolean;
}
</script>
<style scoped lang="scss">
.confirm-mfa {
width: 100%;
&__label {
display: flex;
align-items: center;
justify-content: space-between;
&__info {
font-size: 16px;
line-height: 21px;
color: #354049;
}
&__error {
font-family: 'font_medium', sans-serif;
font-size: 16px;
line-height: 21px;
text-align: right;
color: #ce3030;
}
}
&__input {
width: calc(100% - 40px);
margin-top: 5px;
background: #fff;
border: 1px solid #a9b5c1;
border-radius: 6px;
padding: 15px 20px;
font-size: 16px;
/* Chrome, Safari, Edge, Opera */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
</style>

View File

@ -0,0 +1,322 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="enable-mfa">
<div class="enable-mfa__container">
<h1 class="enable-mfa__container__title">Two-Factor Authentication</h1>
<p class="enable-mfa__container__subtitle" v-if="isScan">
Scan this QR code in your favorite TOTP app to get get started.
</p>
<p class="enable-mfa__container__subtitle max-width" v-if="isEnable">
Enter the authentication code generated in your TOTP app to confirm your account is connected.
</p>
<p class="enable-mfa__container__subtitle" v-if="isCodes">
Save recovery codes.
</p>
<div class="enable-mfa__container__scan" v-if="isScan">
<h2 class="enable-mfa__container__scan__title">Scan this QR Code</h2>
<p class="enable-mfa__container__scan__subtitle">Scan the following QR code in your OTP app.</p>
<div class="enable-mfa__container__scan__qr">
<canvas class="enable-mfa__container__scan__qr__canvas" ref="canvas"/>
</div>
<p class="enable-mfa__container__scan__subtitle">Unable to scan? Use the following code instead:</p>
<p class="enable-mfa__container__scan__secret">{{secret}}</p>
</div>
<div class="enable-mfa__container__confirm" v-if="isEnable">
<h2 class="enable-mfa__container__confirm__title">Confirm Authentication Code</h2>
<ConfirmMFAInput :on-input="onConfirmInput" :is-error="isError"/>
</div>
<div class="enable-mfa__container__codes" v-if="isCodes">
<h2 class="enable-mfa__container__codes__title max-width">
Please save these codes somewhere to be able to recover access to your account.
</h2>
<p
class="enable-mfa__container__codes__value"
v-for="(code, index) in recoveryCodes"
:key="index"
>
{{code}}
</p>
</div>
<div class="enable-mfa__container__buttons">
<VButton
class="cancel-button"
label="Cancel"
width="50%"
height="44px"
is-white="true"
:on-press="toggleModal"
/>
<VButton
v-if="isScan"
label="Continue"
width="50%"
height="44px"
:on-press="showEnable"
/>
<VButton
v-if="isEnable"
label="Enable"
width="50%"
height="44px"
:on-press="enable"
:is-disabled="!confirmPasscode || isLoading"
/>
<VButton
v-if="isCodes"
label="Done"
width="50%"
height="44px"
:on-press="toggleModal"
/>
</div>
<div class="enable-mfa__container__close-container" @click="toggleModal">
<CloseCrossIcon />
</div>
</div>
</div>
</template>
<script lang="ts">
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { Component, Prop, Vue } from 'vue-property-decorator';
import ConfirmMFAInput from '@/components/account/mfa/ConfirmMFAInput.vue';
import VButton from '@/components/common/VButton.vue';
import CloseCrossIcon from '@/../static/images/common/closeCross.svg';
@Component({
components: {
ConfirmMFAInput,
CloseCrossIcon,
VButton,
},
})
export default class EnableMFAPopup extends Vue {
@Prop({default: () => false})
public readonly toggleModal: () => void;
public readonly secret = authenticator.generateSecret();
public readonly qrLink =
`otpauth://totp/${encodeURIComponent(this.$store.getters.user.email)}?secret=${this.secret}&issuer=${encodeURIComponent('STORJ DCS')}&algorithm=SHA1&digits=6&period=30`;
public isScan = true;
public isEnable = false;
public isCodes = false;
public isError = false;
public recoveryCodes: string[] = ['test', 'test', 'test', 'test', 'test', 'test', 'test', 'test', 'test', 'test'];
private confirmPasscode = '';
private isLoading = false;
public $refs!: {
canvas: HTMLCanvasElement;
};
/**
* Mounted lifecycle hook after initial render.
* Renders QR code.
*/
public async mounted(): Promise<void> {
await QRCode.toCanvas(this.$refs.canvas, this.qrLink);
}
/**
* Toggles view to Enable MFA state.
*/
public showEnable(): void {
this.isScan = false;
this.isEnable = true;
}
/**
* Toggles view to MFA Recovery Codes state.
*/
public showCodes(): void {
this.isEnable = false;
this.isCodes = true;
}
/**
* Sets confirmation passcode value from input.
*/
public onConfirmInput(value: string): void {
this.isError = false;
this.confirmPasscode = value;
}
/**
* Enables user MFA and sets view to Recovery Codes state.
*/
public async enable(): Promise<void> {
if (!this.confirmPasscode || this.isLoading || this.isError) return;
this.isLoading = true;
try {
// TODO: enable when backend is ready
// await this.$store.dispatch(USER_ACTIONS.ENABLE_USER_MFA, {secret: this.secret, passcode: this.confirmPasscode})
await this.$notify.success('MFA was enabled successfully');
this.showCodes();
} catch (error) {
await this.$notify.error(error.message);
this.isError = true;
}
this.isLoading = false;
}
}
</script>
<style scoped lang="scss">
.enable-mfa {
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
display: flex;
justify-content: center;
z-index: 1000;
background: rgba(27, 37, 51, 0.75);
&__container {
padding: 60px;
height: fit-content;
margin-top: 100px;
position: relative;
background: #fff;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
font-family: 'font_regular', sans-serif;
&__title {
font-family: 'font_bold', sans-serif;
font-size: 28px;
line-height: 34px;
text-align: center;
color: #000;
margin: 0 0 30px 0;
}
&__subtitle {
font-size: 16px;
line-height: 21px;
text-align: center;
color: #000;
margin: 0 0 45px 0;
}
&__scan {
padding: 25px;
background: #f5f6fa;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
width: calc(100% - 50px);
&__title {
font-family: 'font_bold', sans-serif;
font-size: 16px;
line-height: 19px;
text-align: center;
color: #000;
margin: 0 0 30px 0;
}
&__subtitle {
font-size: 14px;
line-height: 25px;
text-align: center;
color: #000;
}
&__qr {
margin: 30px 0;
background: #fff;
border-radius: 6px;
padding: 10px;
&__canvas {
height: 200px !important;
width: 200px !important;
}
}
&__secret {
margin: 5px 0 0 0;
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 25px;
text-align: center;
color: #000;
}
}
&__confirm,
&__codes {
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;
}
}
&__buttons {
display: flex;
align-items: center;
width: 100%;
margin-top: 30px;
}
&__close-container {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: 30px;
top: 30px;
height: 24px;
width: 24px;
cursor: pointer;
&:hover .close-cross-svg-path {
fill: #2683ff;
}
}
}
}
.cancel-button {
margin-right: 15px;
}
.max-width {
max-width: 485px;
}
@media screen and (max-height: 900px) {
.enable-mfa {
padding-bottom: 20px;
overflow-y: scroll;
}
}
</style>

View File

@ -90,10 +90,10 @@ export function makeUsersModule(api: UsersApi): StoreModule<UsersState> {
return user;
},
[ENABLE_USER_MFA]: async function (_): Promise<void> {
[DISABLE_USER_MFA]: async function (_): Promise<void> {
await api.disableUserMFA();
},
[DISABLE_USER_MFA]: async function (_, request: EnableUserMFARequest): Promise<void> {
[ENABLE_USER_MFA]: async function (_, request: EnableUserMFARequest): Promise<void> {
await api.enableUserMFA(request);
},
[GENERATE_USER_MFA_RECOVERY_CODES]: async function ({commit}: any): Promise<void> {