From 8855c0dff7558500a0c910c50ca42d756489c1ba Mon Sep 17 00:00:00 2001 From: Vitalii Shpital Date: Thu, 8 Jul 2021 19:43:09 +0300 Subject: [PATCH] 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 --- satellite/console/consoleweb/server.go | 3 + scripts/testdata/satellite-config.yaml.lock | 3 + web/satellite/index.html | 1 + web/satellite/src/api/auth.ts | 62 +++++++++++++++++- web/satellite/src/store/modules/users.ts | 68 ++++++++++++-------- web/satellite/src/types/users.ts | 26 ++++++++ web/satellite/tests/unit/mock/api/users.ts | 14 +++- web/satellite/tests/unit/store/users.spec.ts | 60 ++++++++--------- 8 files changed, 179 insertions(+), 58 deletions(-) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 06d8ce0bd..c147e312e 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -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") diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index ac4b6f996..b89d7770e 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -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 diff --git a/web/satellite/index.html b/web/satellite/index.html index b58e23420..32622b8ac 100644 --- a/web/satellite/index.html +++ b/web/satellite/index.html @@ -24,6 +24,7 @@ + {{ .SatelliteName }} diff --git a/web/satellite/src/api/auth.ts b/web/satellite/src/api/auth.ts index bdf6147e1..0734e340a 100644 --- a/web/satellite/src/api/auth.ts +++ b/web/satellite/src/api/auth.ts @@ -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 { + 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 { + 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 { + 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'); + } } diff --git a/web/satellite/src/store/modules/users.ts b/web/satellite/src/store/modules/users.ts index a1366bc7f..743e3e6ad 100644 --- a/web/satellite/src/store/modules/users.ts +++ b/web/satellite/src/store/modules/users.ts @@ -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 { +export function makeUsersModule(api: UsersApi): StoreModule { 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 { return user; }, + [ENABLE_USER_MFA]: async function (_): Promise { + await api.disableUserMFA(); + }, + [DISABLE_USER_MFA]: async function (_, request: EnableUserMFARequest): Promise { + await api.enableUserMFA(request); + }, + [GENERATE_USER_MFA_RECOVERY_CODES]: async function ({commit}: any): Promise { + 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(), }, }; } diff --git a/web/satellite/src/types/users.ts b/web/satellite/src/types/users.ts index 3c8cc0755..f9460af50 100644 --- a/web/satellite/src/types/users.ts +++ b/web/satellite/src/types/users.ts @@ -19,6 +19,24 @@ export interface UsersApi { * @throws Error */ get(): Promise; + /** + * Enable user's MFA. + * + * @throws Error + */ + enableUserMFA(request: EnableUserMFARequest): Promise; + /** + * Disable user's MFA. + * + * @throws Error + */ + disableUserMFA(): Promise; + /** + * Generate user's MFA recovery codes. + * + * @throws Error + */ + generateUserMFARecoveryCodes(): Promise; } /** @@ -68,3 +86,11 @@ export class UpdatedUser { return !!this.fullName; } } + +/** + * Represents request to enable user's MFA. + */ +export interface EnableUserMFARequest { + secret: string; + passcode: string; +} diff --git a/web/satellite/tests/unit/mock/api/users.ts b/web/satellite/tests/unit/mock/api/users.ts index 34406348f..3c5cfb7a1 100644 --- a/web/satellite/tests/unit/mock/api/users.ts +++ b/web/satellite/tests/unit/mock/api/users.ts @@ -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 { throw new Error('not implemented'); } + + public enableUserMFA(_: EnableUserMFARequest): Promise { + return Promise.resolve(); + } + + public disableUserMFA(): Promise { + return Promise.resolve(); + } + + public generateUserMFARecoveryCodes(): Promise { + return Promise.resolve(['test', 'test1', 'test2']); + } } diff --git a/web/satellite/tests/unit/store/users.spec.ts b/web/satellite/tests/unit/store/users.spec.ts index 98d6a01ab..ac07150f2 100644 --- a/web/satellite/tests/unit/store/users.spec.ts +++ b/web/satellite/tests/unit/store/users.spec.ts @@ -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()); }); });