web/satellite: added MFA feature flag, updated client-side api and Vuex store module

Added feature flag for MFA
Added new client-side api call to enable MFA returning secret
Updated users Vuex module to include new API call

Change-Id: Ia9e10f68c4a7da39b4f7c1073e657c2de98fb0db
This commit is contained in:
Vitalii Shpital 2021-07-08 19:43:09 +03:00
parent ee107fe8cd
commit 8855c0dff7
8 changed files with 179 additions and 58 deletions

View File

@ -89,6 +89,7 @@ type Config struct {
CSPEnabled bool `help:"indicates if Content Security Policy is enabled" devDefault:"false" releaseDefault:"true"`
LinksharingURL string `help:"url link for linksharing requests" default:"https://link.us1.storjshare.io"`
PathwayOverviewEnabled bool `help:"indicates if the overview onboarding step should render with pathways" default:"true"`
MFAEnabled bool `help:"indicates if MFA is enabled" default:"false"`
RateLimit web.IPRateLimiterConfig
@ -360,6 +361,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
ObjectPrice string
RecaptchaEnabled bool
RecaptchaSiteKey string
MFAEnabled bool
}
data.ExternalAddress = server.config.ExternalAddress
@ -384,6 +386,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
data.ObjectPrice = server.pricing.ObjectPrice
data.RecaptchaEnabled = server.config.Recaptcha.Enabled
data.RecaptchaSiteKey = server.config.Recaptcha.SiteKey
data.MFAEnabled = server.config.MFAEnabled
if server.templates.index == nil {
server.log.Error("index template is not set")

View File

@ -124,6 +124,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# url link for linksharing requests
# console.linksharing-url: https://link.us1.storjshare.io
# indicates if MFA is enabled
# console.mfa-enabled: false
# enable open registration
# console.open-registration-enabled: false

View File

@ -24,6 +24,7 @@
<meta name="object-price" content="{{ .ObjectPrice }}">
<meta name="recaptcha-enabled" content="{{ .RecaptchaEnabled }}">
<meta name="recaptcha-site-key" content="{{ .RecaptchaSiteKey }}">
<meta name="mfa-enabled" content="{{ .MFAEnabled }}">
<title>{{ .SatelliteName }}</title>
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAACDVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////8nbP8obf8pbf8qbv8rb/8sb/8tcP8ucf8vcf8vcv8xc/8zdP81df81dv82dv83d/84eP85eP86ef87ev89e/8+fP8/fP9Aff9Bfv9Cfv9Df/9EgP9FgP9Ggf9Hgv9Jg/9LhP9Mhf9Nhv9Oh/9Ph/9RiP9Rif9Sif9Ui/9Vi/9WjP9Xjf9Yjf9aj/9dkf9ekf9ilP9jlf9llv9nl/9omP9rmv9sm/9tnP9vnf9wnv9yn/91of92ov93o/94o/98pv9/qP+Bqf+Cqv+Eq/+FrP+Hrf+Irv+Jr/+Kr/+Msf+Nsv+Stf+Ttf+Ttv+Utv+Vt/+WuP+XuP+Zuf+Zuv+hv/+kwf+lwv+mwv+nw/+oxP+pxP+pxf+qxf+rxv+yy/+0zP+1zf+3zv+4z/+60P+70f+90v+/0/+/1P/B1f/D1v/E1//F1//F2P/G2P/H2f/I2v/J2v/K2//L3P/P3v/Q3//R4P/S4P/V4v/V4//W4//X5P/Y5P/b5v/b5//c5//d6P/e6f/f6f/g6v/h6//j7P/k7f/l7f/m7v/q8f/r8f/u8//w9f/x9f/x9v/z9//0+P/2+f/3+f/3+v/4+v/5+//6/P/8/f/9/v/+/v////9uCbVDAAAAFXRSTlMABAU4Ozw9PpSWl5ilp6ip4+Tl/P6nIcp/AAAAAWJLR0SuuWuTpwAAAh5JREFUOMtjYGBgYOcXEl6HAYSF+FgZQICJex1OwMkEVIAi3+Xh1ozM5wKaj8xfpBwcITsbWYSNgR+JtzpJYvU6jbAVSEK8DEIITpOZqnxItISWfgVCTJAB7v4ZXpKRC9uMNCqXJci6TID7hQFMrV2zJE7abTKQFesDJGb7SYTOX7sGLAVWUKCgrGZcDeaDFaxb12alqC6XDlMwTyKnRLJ1HbKCddNEc0skJkAVdEssXatRiKqgVmLlatUqqILVpuaOEnLJy4GsIhONuHlAOldVwtJWcwnMDb2i4dPKdHVKV3uqRCdYqU9psVDOmh0vUQN35FTRhevWLU+V0FeZBdTtpSQRvgAoKtuMqmBdpKxvKYjXJ+o+cx0WBRPFO6ABHuesMheLghIdePiutc7AoqBLchZchVMSFgUr9HTS8sEgL1C0E1XBRNGUeeV6OlFONjbqSjY2Nv7mKjnzMyXqYQrW2OsYS8smLkOE5OpsFSkdQ6PlUAU9EgtXq6MFdZ3EkpVKNVAFc8TKW6QbURVMFK1slOyGuSFdUkLOoQtZwSRXaRmpKLgj1y1eMjdIImguTMHCCAnvGcuXQhIMPMl1O8hnrOy31GtfnaNi3oRIcohEu7ZY20DZK0DGTCV7NVKi5UVK40vDJVatU/dfgCTEw8AsgsSdLx+TKjUdOeMAsycnMr/BzrIcmc8ByrycuDMvByM4f7PyCmLL/gK8LEBJALYsGEdXEyupAAAAAElFTkSuQmCC" type="image/x-icon">
<link rel="dns-prefetch" href="https://js.stripe.com">

View File

@ -5,7 +5,7 @@ import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest';
import { ErrorEmailUsed } from '@/api/errors/ErrorEmailUsed';
import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import { UpdatedUser, User } from '@/types/users';
import { EnableUserMFARequest, UpdatedUser, User } from '@/types/users';
import { HttpClient } from '@/utils/httpClient';
/**
@ -242,4 +242,64 @@ export class AuthHttpApi {
return await response.json();
}
/**
* Used to enable user's MFA.
*
* @throws Error
*/
public async enableUserMFA(body: EnableUserMFARequest): Promise<void> {
const path = `${this.ROOT_PATH}/mfa/enable`;
const response = await this.http.post(path, JSON.stringify(body));
if (response.ok) {
return;
}
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('Can not enable MFA. Please try again later');
}
/**
* Used to disable user's MFA.
*
* @throws Error
*/
public async disableUserMFA(): Promise<void> {
const path = `${this.ROOT_PATH}/mfa/disable`;
const response = await this.http.post(path, null);
if (response.ok) {
return;
}
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('Can not enable MFA. Please try again later');
}
/**
* Used to generate user's MFA recovery codes.
*
* @throws Error
*/
public async generateUserMFARecoveryCodes(): Promise<string[]> {
const path = `${this.ROOT_PATH}/mfa/generate-recovery-codes`;
const response = await this.http.post(path, null);
if (response.ok) {
return await response.json();
}
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('Can not enable MFA. Please try again later');
}
}

View File

@ -2,29 +2,42 @@
// See LICENSE for copying information.
import { StoreModule } from '@/store';
import { UpdatedUser, User, UsersApi } from '@/types/users';
import { EnableUserMFARequest, UpdatedUser, User, UsersApi } from '@/types/users';
import { MetaUtils } from '@/utils/meta';
export const USER_ACTIONS = {
UPDATE: 'updateUser',
GET: 'getUser',
ENABLE_USER_MFA: 'enableUserMFA',
DISABLE_USER_MFA: 'disableUserMFA',
GENERATE_USER_MFA_RECOVERY_CODES: 'generateUserMFARecoveryCodes',
CLEAR: 'clearUser',
};
export const USER_MUTATIONS = {
SET_USER: 'setUser',
SET_USER_MFA_RECOVERY_CODES: 'setUserMFARecoveryCodes',
UPDATE_USER: 'updateUser',
CLEAR: 'clearUser',
};
export class UsersState {
public user: User = new User();
public userMFARecoveryCodes: string[] = [];
}
const {
GET,
UPDATE,
ENABLE_USER_MFA,
DISABLE_USER_MFA,
GENERATE_USER_MFA_RECOVERY_CODES,
} = USER_ACTIONS;
const {
SET_USER,
UPDATE_USER,
SET_USER_MFA_RECOVERY_CODES,
CLEAR,
} = USER_MUTATIONS;
@ -33,42 +46,34 @@ const {
*
* @param api - users api
*/
export function makeUsersModule(api: UsersApi): StoreModule<User> {
export function makeUsersModule(api: UsersApi): StoreModule<UsersState> {
return {
state: new User(),
state: new UsersState(),
mutations: {
[SET_USER](state: User, user: User): void {
state.id = user.id;
state.email = user.email;
state.shortName = user.shortName;
state.fullName = user.fullName;
state.partnerId = user.partnerId;
[SET_USER](state: UsersState, user: User): void {
state.user = user;
if (user.projectLimit === 0) {
const limitFromConfig = MetaUtils.getMetaContent('default-project-limit');
state.projectLimit = parseInt(limitFromConfig);
state.user.projectLimit = parseInt(limitFromConfig);
return;
}
state.projectLimit = user.projectLimit;
state.paidTier = user.paidTier;
state.user.projectLimit = user.projectLimit;
},
[CLEAR](state: User): void {
state.id = '';
state.email = '';
state.shortName = '';
state.fullName = '';
state.partnerId = '';
state.projectLimit = 1;
[CLEAR](state: UsersState): void {
state.user = new User();
state.user.projectLimit = 1;
},
[UPDATE_USER](state: User, user: UpdatedUser): void {
state.fullName = user.fullName;
state.shortName = user.shortName;
[UPDATE_USER](state: UsersState, user: UpdatedUser): void {
state.user.fullName = user.fullName;
state.user.shortName = user.shortName;
},
[SET_USER_MFA_RECOVERY_CODES](state: UsersState, codes: string[]): void {
state.userMFARecoveryCodes = codes;
},
},
@ -85,14 +90,25 @@ export function makeUsersModule(api: UsersApi): StoreModule<User> {
return user;
},
[ENABLE_USER_MFA]: async function (_): Promise<void> {
await api.disableUserMFA();
},
[DISABLE_USER_MFA]: async function (_, request: EnableUserMFARequest): Promise<void> {
await api.enableUserMFA(request);
},
[GENERATE_USER_MFA_RECOVERY_CODES]: async function ({commit}: any): Promise<void> {
const codes = await api.generateUserMFARecoveryCodes();
commit(SET_USER_MFA_RECOVERY_CODES, codes);
},
[CLEAR]: function({commit}: any) {
commit(CLEAR);
},
},
getters: {
user: (state: User): User => state,
userName: (state: User): string => state.getFullName(),
user: (state: UsersState): User => state.user,
userName: (state: UsersState): string => state.user.getFullName(),
},
};
}

View File

@ -19,6 +19,24 @@ export interface UsersApi {
* @throws Error
*/
get(): Promise<User>;
/**
* Enable user's MFA.
*
* @throws Error
*/
enableUserMFA(request: EnableUserMFARequest): Promise<void>;
/**
* Disable user's MFA.
*
* @throws Error
*/
disableUserMFA(): Promise<void>;
/**
* Generate user's MFA recovery codes.
*
* @throws Error
*/
generateUserMFARecoveryCodes(): Promise<string[]>;
}
/**
@ -68,3 +86,11 @@ export class UpdatedUser {
return !!this.fullName;
}
}
/**
* Represents request to enable user's MFA.
*/
export interface EnableUserMFARequest {
secret: string;
passcode: string;
}

View File

@ -1,7 +1,7 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { UpdatedUser, User, UsersApi } from '@/types/users';
import { EnableUserMFARequest, UpdatedUser, User, UsersApi } from '@/types/users';
/**
* Mock for UsersApi
@ -20,4 +20,16 @@ export class UsersApiMock implements UsersApi {
public update(user: UpdatedUser): Promise<void> {
throw new Error('not implemented');
}
public enableUserMFA(_: EnableUserMFARequest): Promise<void> {
return Promise.resolve();
}
public disableUserMFA(): Promise<void> {
return Promise.resolve();
}
public generateUserMFARecoveryCodes(): Promise<string[]> {
return Promise.resolve(['test', 'test1', 'test2']);
}
}

View File

@ -26,19 +26,19 @@ describe('mutations', () => {
store.commit(USER_MUTATIONS.SET_USER, user);
expect(store.state.id).toBe(user.id);
expect(store.state.email).toBe(user.email);
expect(store.state.fullName).toBe(user.fullName);
expect(store.state.shortName).toBe(user.shortName);
expect(store.state.user.id).toBe(user.id);
expect(store.state.user.email).toBe(user.email);
expect(store.state.user.fullName).toBe(user.fullName);
expect(store.state.user.shortName).toBe(user.shortName);
});
it('clear user', () => {
store.commit(USER_MUTATIONS.CLEAR);
expect(store.state.id).toBe('');
expect(store.state.email).toBe('');
expect(store.state.fullName).toBe('');
expect(store.state.shortName).toBe('');
expect(store.state.user.id).toBe('');
expect(store.state.user.email).toBe('');
expect(store.state.user.fullName).toBe('');
expect(store.state.user.shortName).toBe('');
});
it('Update user', () => {
@ -46,8 +46,8 @@ describe('mutations', () => {
store.commit(USER_MUTATIONS.UPDATE_USER, user);
expect(store.state.fullName).toBe(user.fullName);
expect(store.state.shortName).toBe(user.shortName);
expect(store.state.user.fullName).toBe(user.fullName);
expect(store.state.user.shortName).toBe(user.shortName);
});
});
@ -64,8 +64,8 @@ describe('actions', () => {
await store.dispatch(UPDATE, user);
expect(store.state.fullName).toBe('fullName1');
expect(store.state.shortName).toBe('shortName2');
expect(store.state.user.fullName).toBe('fullName1');
expect(store.state.user.shortName).toBe('shortName2');
});
it('update throws an error when api call fails', async () => {
@ -77,19 +77,19 @@ describe('actions', () => {
await store.dispatch(UPDATE, newUser);
expect(true).toBe(false);
} catch (error) {
expect(store.state.fullName).toBe(oldUser.fullName);
expect(store.state.shortName).toBe(oldUser.shortName);
expect(store.state.user.fullName).toBe(oldUser.fullName);
expect(store.state.user.shortName).toBe(oldUser.shortName);
}
});
it('clears state', async () => {
await store.dispatch(CLEAR);
expect(store.state.fullName).toBe('');
expect(store.state.shortName).toBe('');
expect(store.state.email).toBe('');
expect(store.state.partnerId).toBe('');
expect(store.state.id).toBe('');
expect(store.state.user.fullName).toBe('');
expect(store.state.user.shortName).toBe('');
expect(store.state.user.email).toBe('');
expect(store.state.user.partnerId).toBe('');
expect(store.state.user.id).toBe('');
});
it('success get user', async () => {
@ -101,10 +101,10 @@ describe('actions', () => {
await store.dispatch(GET);
expect(store.state.id).toBe(user.id);
expect(store.state.shortName).toBe(user.shortName);
expect(store.state.fullName).toBe(user.fullName);
expect(store.state.email).toBe(user.email);
expect(store.state.user.id).toBe(user.id);
expect(store.state.user.shortName).toBe(user.shortName);
expect(store.state.user.fullName).toBe(user.fullName);
expect(store.state.user.email).toBe(user.email);
});
it('get throws an error when api call fails', async () => {
@ -115,8 +115,8 @@ describe('actions', () => {
await store.dispatch(GET);
expect(true).toBe(false);
} catch (error) {
expect(store.state.fullName).toBe(user.fullName);
expect(store.state.shortName).toBe(user.shortName);
expect(store.state.user.fullName).toBe(user.fullName);
expect(store.state.user.shortName).toBe(user.shortName);
}
});
});
@ -125,15 +125,15 @@ describe('getters', () => {
it('user model', function () {
const retrievedUser = store.getters.user;
expect(retrievedUser.id).toBe(store.state.id);
expect(retrievedUser.fullName).toBe(store.state.fullName);
expect(retrievedUser.shortName).toBe(store.state.shortName);
expect(retrievedUser.email).toBe(store.state.email);
expect(retrievedUser.id).toBe(store.state.user.id);
expect(retrievedUser.fullName).toBe(store.state.user.fullName);
expect(retrievedUser.shortName).toBe(store.state.user.shortName);
expect(retrievedUser.email).toBe(store.state.user.email);
});
it('user name', function () {
const retrievedUserName = store.getters.userName;
expect(retrievedUserName).toBe(store.state.getFullName());
expect(retrievedUserName).toBe(store.state.user.getFullName());
});
});