From 2ebdc5ff2fb27c07cfbf901074bb6b50e64d1d91 Mon Sep 17 00:00:00 2001 From: Vitalii Date: Thu, 15 Dec 2022 14:52:28 +0200 Subject: [PATCH] web/satellite: unauthorized error (401) interception for http requests Implemented interception for http requests. We redirect user to login page on every 401 response. Issue: https://github.com/storj/storj/issues/5339 Change-Id: Icba4fc0031cb2b4e682a1be078cdcf95b7fa6bfe --- .../console/consoleweb/consoleapi/auth.go | 10 +- .../console/consoleweb/endpoints_test.go | 2 +- satellite/console/mfa.go | 2 +- satellite/console/service.go | 6 +- web/satellite/src/api/abtesting.ts | 5 - web/satellite/src/api/accessGrants.ts | 5 - web/satellite/src/api/auth.ts | 58 +----------- web/satellite/src/api/buckets.ts | 5 - .../src/api/errors/ErrorUnauthorized.ts | 2 +- web/satellite/src/api/payments.ts | 86 ++++------------- web/satellite/src/api/projects.ts | 93 +++++++++---------- web/satellite/src/utils/apollo.ts | 2 +- web/satellite/src/utils/httpClient.ts | 37 +++++++- 13 files changed, 114 insertions(+), 199 deletions(-) diff --git a/satellite/console/consoleweb/consoleapi/auth.go b/satellite/console/consoleweb/consoleapi/auth.go index e56e787cf..41339e4f8 100644 --- a/satellite/console/consoleweb/consoleapi/auth.go +++ b/satellite/console/consoleweb/consoleapi/auth.go @@ -932,7 +932,7 @@ func (a *Auth) serveJSONError(w http.ResponseWriter, err error) { // getStatusCode returns http.StatusCode depends on console error class. func (a *Auth) getStatusCode(err error) int { switch { - case console.ErrValidation.Has(err), console.ErrCaptcha.Has(err), console.ErrMFAMissing.Has(err): + case console.ErrValidation.Has(err), console.ErrCaptcha.Has(err), console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err), console.ErrChangePassword.Has(err): return http.StatusBadRequest case console.ErrUnauthorized.Has(err), console.ErrTokenExpiration.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err): return http.StatusUnauthorized @@ -940,8 +940,6 @@ func (a *Auth) getStatusCode(err error) int { return http.StatusConflict case errors.Is(err, errNotImplemented): return http.StatusNotImplemented - case console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err): - return http.StatusBadRequest default: return http.StatusInternalServerError } @@ -966,12 +964,12 @@ func (a *Auth) getUserErrorMessage(err error) string { case console.ErrMFAConflict.Has(err): return "Expected either passcode or recovery code, but got both" case console.ErrMFAPasscode.Has(err): - return "The MFA passcode is not valid or has expired. You have just used up one of your login attempts" + return "The MFA passcode is not valid or has expired" case console.ErrMFARecoveryCode.Has(err): - return "The MFA recovery code is not valid or has been previously used. You have just used up one of your login attempts" + return "The MFA recovery code is not valid or has been previously used" case console.ErrLoginCredentials.Has(err): return "Your login credentials are incorrect, please try again" - case console.ErrValidation.Has(err): + case console.ErrValidation.Has(err), console.ErrChangePassword.Has(err): return err.Error() case errors.Is(err, errNotImplemented): return "The server is incapable of fulfilling the request" diff --git a/satellite/console/consoleweb/endpoints_test.go b/satellite/console/consoleweb/endpoints_test.go index e9a028ed7..bab6fad30 100644 --- a/satellite/console/consoleweb/endpoints_test.go +++ b/satellite/console/consoleweb/endpoints_test.go @@ -57,7 +57,7 @@ func TestAuth(t *testing.T) { "newPassword": user.password + "2", })) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) _ = body //TODO: require.Contains(t, body, "password was incorrect") } diff --git a/satellite/console/mfa.go b/satellite/console/mfa.go index 99ea65c77..41207cc36 100644 --- a/satellite/console/mfa.go +++ b/satellite/console/mfa.go @@ -136,7 +136,7 @@ func (s *Service) DisableUserMFA(ctx context.Context, passcode string, t time.Ti } } if !found { - return ErrUnauthorized.Wrap(ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg)) + return ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg) } } else if passcode != "" { valid, err := ValidateMFAPasscode(passcode, user.MFASecretKey, t) diff --git a/satellite/console/service.go b/satellite/console/service.go index 830b6da43..fcefa07ff 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -57,6 +57,7 @@ const ( emailNotFoundErrMsg = "There are no users with the specified email" passwordRecoveryTokenIsExpiredErrMsg = "Your password recovery link has expired, please request another one" credentialsErrMsg = "Your login credentials are incorrect, please try again" + changePasswordErrMsg = "Your old password is incorrect, please try again" passwordTooShortErrMsg = "Your password needs to be at least %d characters long" passwordTooLongErrMsg = "Your password must be no longer than %d characters" projectOwnerDeletionForbiddenErrMsg = "%s is a project owner and can not be deleted" @@ -94,6 +95,9 @@ var ( // ErrLoginCredentials occurs when provided invalid login credentials. ErrLoginCredentials = errs.Class("login credentials") + // ErrChangePassword occurs when provided old password is incorrect. + ErrChangePassword = errs.Class("change password") + // ErrEmailUsed is error type that occurs on repeating auth attempts with email. ErrEmailUsed = errs.Class("email used") @@ -1306,7 +1310,7 @@ func (s *Service) ChangePassword(ctx context.Context, pass, newPass string) (err err = bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(pass)) if err != nil { - return ErrUnauthorized.New(credentialsErrMsg) + return ErrChangePassword.New(changePasswordErrMsg) } if err := ValidatePassword(newPass); err != nil { diff --git a/web/satellite/src/api/abtesting.ts b/web/satellite/src/api/abtesting.ts index 9d255e746..e80a0989d 100644 --- a/web/satellite/src/api/abtesting.ts +++ b/web/satellite/src/api/abtesting.ts @@ -1,7 +1,6 @@ // Copyright (C) 2022 Storj Labs, Inc. // See LICENSE for copying information. -import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { HttpClient } from '@/utils/httpClient'; import { ABHitAction, ABTestApi, ABTestValues } from '@/types/abtesting'; @@ -29,10 +28,6 @@ export class ABHttpApi implements ABTestApi { ); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - // use default values on error return new ABTestValues(); } diff --git a/web/satellite/src/api/accessGrants.ts b/web/satellite/src/api/accessGrants.ts index 719d84102..f33d85a8b 100644 --- a/web/satellite/src/api/accessGrants.ts +++ b/web/satellite/src/api/accessGrants.ts @@ -2,7 +2,6 @@ // See LICENSE for copying information. import { BaseGql } from '@/api/baseGql'; -import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { AccessGrant, AccessGrantCursor, @@ -145,10 +144,6 @@ export class AccessGrantsApiGql extends BaseGql implements AccessGrantsApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not delete access grant'); } diff --git a/web/satellite/src/api/auth.ts b/web/satellite/src/api/auth.ts index 50d62f102..92f8b28c2 100644 --- a/web/satellite/src/api/auth.ts +++ b/web/satellite/src/api/auth.ts @@ -4,7 +4,6 @@ import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest'; import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired'; import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests'; -import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { TokenInfo, UpdatedUser, User, UsersApi } from '@/types/users'; import { HttpClient } from '@/utils/httpClient'; import { ErrorTokenExpired } from '@/api/errors/ErrorTokenExpired'; @@ -75,8 +74,6 @@ export class AuthHttpApi implements UsersApi { switch (response.status) { case 400: throw new ErrorBadRequest(errMsg); - case 401: - throw new ErrorUnauthorized(errMsg); case 429: throw new ErrorTooManyRequests(errMsg); default: @@ -97,10 +94,6 @@ export class AuthHttpApi implements UsersApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not logout. Please try again later'); } @@ -125,8 +118,6 @@ export class AuthHttpApi implements UsersApi { const result = await response.json(); const errMsg = result.error || 'Failed to send password reset link'; switch (response.status) { - case 404: - throw new ErrorUnauthorized(errMsg); case 429: throw new ErrorTooManyRequests(errMsg); default: @@ -185,10 +176,6 @@ export class AuthHttpApi implements UsersApi { ); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not get user data'); } @@ -210,14 +197,8 @@ export class AuthHttpApi implements UsersApi { return; } - switch (response.status) { - case 401: { - throw new Error('old password is incorrect, please try again'); - } - default: { - throw new Error('can not change password'); - } - } + const result = await response.json(); + throw new Error(result.error); } /** @@ -236,10 +217,6 @@ export class AuthHttpApi implements UsersApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not delete user'); } @@ -279,8 +256,6 @@ export class AuthHttpApi implements UsersApi { switch (response.status) { case 400: throw new ErrorBadRequest(errMsg); - case 401: - throw new ErrorUnauthorized(errMsg); case 429: throw new ErrorTooManyRequests(errMsg); default: @@ -302,10 +277,6 @@ export class AuthHttpApi implements UsersApi { return await response.json(); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not generate MFA secret. Please try again later'); } @@ -326,10 +297,6 @@ export class AuthHttpApi implements UsersApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not enable MFA. Please try again later'); } @@ -352,15 +319,8 @@ export class AuthHttpApi implements UsersApi { } const result = await response.json(); - if (!response.ok) { - const errMsg = result.error || 'Cannot disable MFA. Please try again later'; - switch (response.status) { - case 401: - throw new ErrorUnauthorized(errMsg); - default: - throw new Error(errMsg); - } - } + const errMsg = result.error || 'Cannot disable MFA. Please try again later'; + throw new Error(errMsg); } /** @@ -376,10 +336,6 @@ export class AuthHttpApi implements UsersApi { return await response.json(); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not generate MFA recovery codes. Please try again later'); } @@ -426,8 +382,6 @@ export class AuthHttpApi implements UsersApi { switch (response.status) { case 400: throw new ErrorBadRequest(errMsg); - case 401: - throw new ErrorUnauthorized(errMsg); default: throw new Error(errMsg); } @@ -447,10 +401,6 @@ export class AuthHttpApi implements UsersApi { return new Date(await response.json()); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Unable to refresh session.'); } } diff --git a/web/satellite/src/api/buckets.ts b/web/satellite/src/api/buckets.ts index 99f361726..490520a4c 100644 --- a/web/satellite/src/api/buckets.ts +++ b/web/satellite/src/api/buckets.ts @@ -2,7 +2,6 @@ // See LICENSE for copying information. import { BaseGql } from '@/api/baseGql'; -import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { Bucket, BucketCursor, BucketPage, BucketsApi } from '@/types/buckets'; import { HttpClient } from '@/utils/httpClient'; @@ -70,10 +69,6 @@ export class BucketsApiGql extends BaseGql implements BucketsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not get bucket names'); } diff --git a/web/satellite/src/api/errors/ErrorUnauthorized.ts b/web/satellite/src/api/errors/ErrorUnauthorized.ts index bfd3151da..8616ad03d 100644 --- a/web/satellite/src/api/errors/ErrorUnauthorized.ts +++ b/web/satellite/src/api/errors/ErrorUnauthorized.ts @@ -5,7 +5,7 @@ * ErrorUnauthorized is a custom error type for performing unauthorized operations. */ export class ErrorUnauthorized extends Error { - public constructor(message = 'authorization required') { + public constructor(message = 'Authorization required') { super(message); } } diff --git a/web/satellite/src/api/payments.ts b/web/satellite/src/api/payments.ts index 7ac733114..3122cc244 100644 --- a/web/satellite/src/api/payments.ts +++ b/web/satellite/src/api/payments.ts @@ -3,7 +3,6 @@ import { ErrorTooManyRequests } from './errors/ErrorTooManyRequests'; -import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { AccountBalance, Coupon, @@ -37,10 +36,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not get account balance'); } @@ -66,10 +61,6 @@ export class PaymentsHttpApi implements PaymentsApi { return couponType; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not setup account'); } @@ -83,10 +74,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not get projects charges'); } @@ -124,10 +111,6 @@ export class PaymentsHttpApi implements PaymentsApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not add credit card'); } @@ -145,10 +128,6 @@ export class PaymentsHttpApi implements PaymentsApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not remove credit card'); } @@ -163,9 +142,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } throw new Error('can not list credit cards'); } @@ -192,10 +168,6 @@ export class PaymentsHttpApi implements PaymentsApi { return; } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('can not make credit card default'); } @@ -210,9 +182,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } throw new Error('can not list billing history'); } @@ -248,9 +217,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } throw new Error('Can not list token payment history'); } @@ -285,33 +251,31 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.patch(path, couponCode); const errMsg = `Could not apply coupon code "${couponCode}"`; - if (response.ok) { - const coupon = await response.json(); - - if (!coupon) { + if (!response.ok) { + switch (response.status) { + case 429: + throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes'); + default: throw new Error(errMsg); } - - return new Coupon( - coupon.id, - coupon.promoCode, - coupon.name, - coupon.amountOff, - coupon.percentOff, - new Date(coupon.addedAt), - coupon.expiresAt ? new Date(coupon.expiresAt) : null, - coupon.duration, - ); } - switch (response.status) { - case 429: - throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes'); - case 401: - throw new ErrorUnauthorized(errMsg); - default: + const coupon = await response.json(); + + if (!coupon) { throw new Error(errMsg); } + + return new Coupon( + coupon.id, + coupon.promoCode, + coupon.name, + coupon.amountOff, + coupon.percentOff, + new Date(coupon.addedAt), + coupon.expiresAt ? new Date(coupon.expiresAt) : null, + coupon.duration, + ); } /** @@ -323,10 +287,6 @@ export class PaymentsHttpApi implements PaymentsApi { const path = `${this.ROOT_PATH}/coupon`; const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('cannot retrieve coupon'); } @@ -359,10 +319,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.get(path); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not get wallet'); } @@ -385,10 +341,6 @@ export class PaymentsHttpApi implements PaymentsApi { const response = await this.client.post(path, null); if (!response.ok) { - if (response.status === 401) { - throw new ErrorUnauthorized(); - } - throw new Error('Can not claim new wallet'); } diff --git a/web/satellite/src/api/projects.ts b/web/satellite/src/api/projects.ts index 1589f46a0..0114c1a42 100644 --- a/web/satellite/src/api/projects.ts +++ b/web/satellite/src/api/projects.ts @@ -2,7 +2,6 @@ // See LICENSE for copying information. import { BaseGql } from '@/api/baseGql'; -import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { DataStamp, Project, @@ -144,24 +143,21 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi { const path = `${this.ROOT_PATH}/${projectId}/usage-limits`; const response = await this.http.get(path); - if (response.ok) { - const limits = await response.json(); - - return new ProjectLimits( - limits.bandwidthLimit, - limits.bandwidthUsed, - limits.storageLimit, - limits.storageUsed, - limits.objectCount, - limits.segmentCount, - ); + if (!response.ok) { + throw new Error('can not get usage limits'); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } + const limits = await response.json(); + + return new ProjectLimits( + limits.bandwidthLimit, + limits.bandwidthUsed, + limits.storageLimit, + limits.storageUsed, + limits.objectCount, + limits.segmentCount, + ); - throw new Error('can not get usage limits'); } /** @@ -173,22 +169,19 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi { const path = `${this.ROOT_PATH}/usage-limits`; const response = await this.http.get(path); - if (response.ok) { - const limits = await response.json(); + if (!response.ok) { + throw new Error('can not get total usage limits'); - return new ProjectLimits( - limits.bandwidthLimit, - limits.bandwidthUsed, - limits.storageLimit, - limits.storageUsed, - ); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } + const limits = await response.json(); - throw new Error('can not get total usage limits'); + return new ProjectLimits( + limits.bandwidthLimit, + limits.bandwidthUsed, + limits.storageLimit, + limits.storageUsed, + ); } /** @@ -205,33 +198,30 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi { const path = `${this.ROOT_PATH}/${projectId}/daily-usage?from=${since}&to=${before}`; const response = await this.http.get(path); - if (response.ok) { - const usage = await response.json(); + if (!response.ok) { + throw new Error('Can not get project daily usage'); - return new ProjectsStorageBandwidthDaily( - usage.storageUsage.map(el => { - const date = new Date(el.date); - date.setHours(0, 0, 0, 0); - return new DataStamp(el.value, date); - }), - usage.allocatedBandwidthUsage.map(el => { - const date = new Date(el.date); - date.setHours(0, 0, 0, 0); - return new DataStamp(el.value, date); - }), - usage.settledBandwidthUsage.map(el => { - const date = new Date(el.date); - date.setHours(0, 0, 0, 0); - return new DataStamp(el.value, date); - }), - ); } - if (response.status === 401) { - throw new ErrorUnauthorized(); - } + const usage = await response.json(); - throw new Error('Can not get project daily usage'); + return new ProjectsStorageBandwidthDaily( + usage.storageUsage.map(el => { + const date = new Date(el.date); + date.setHours(0, 0, 0, 0); + return new DataStamp(el.value, date); + }), + usage.allocatedBandwidthUsage.map(el => { + const date = new Date(el.date); + date.setHours(0, 0, 0, 0); + return new DataStamp(el.value, date); + }), + usage.settledBandwidthUsage.map(el => { + const date = new Date(el.date); + date.setHours(0, 0, 0, 0); + return new DataStamp(el.value, date); + }), + ); } public async getSalt(projectId: string): Promise { @@ -240,6 +230,7 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi { if (response.ok) { return await response.json(); } + throw new Error('Can not get project salt'); } diff --git a/web/satellite/src/utils/apollo.ts b/web/satellite/src/utils/apollo.ts index 471dd3e0a..5b1e3acd2 100644 --- a/web/satellite/src/utils/apollo.ts +++ b/web/satellite/src/utils/apollo.ts @@ -43,7 +43,7 @@ const errorLink = onError(({ graphQLErrors, networkError }) => { Vue.prototype.$notify.error('Session token expired', AnalyticsErrorEventSource.OVERALL_SESSION_EXPIRED_ERROR); setTimeout(() => { window.location.href = window.location.origin + '/login'; - }, 3000); + }, 2000); } else { nError.result && Vue.prototype.$notify.error(nError.result.error, AnalyticsErrorEventSource.OVERALL_GRAPHQL_ERROR); } diff --git a/web/satellite/src/utils/httpClient.ts b/web/satellite/src/utils/httpClient.ts index d76f831d2..b65b6ad09 100644 --- a/web/satellite/src/utils/httpClient.ts +++ b/web/satellite/src/utils/httpClient.ts @@ -1,6 +1,8 @@ // Copyright (C) 2019 Storj Labs, Inc. // See LICENSE for copying information. +import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; + /** * HttpClient is a custom wrapper around fetch api. * Exposes get, post and delete methods for JSON strings. @@ -22,7 +24,13 @@ export class HttpClient { 'Content-Type': 'application/json', }; - return await fetch(path, request); + const response = await fetch(path, request); + if (response.status === 401) { + await this.handleUnauthorized(); + throw new ErrorUnauthorized(); + } + + return response; } /** @@ -67,4 +75,31 @@ export class HttpClient { public async delete(path: string): Promise { return this.sendJSON('DELETE', path, null); } + + /** + * Handles unauthorized actions. + * Call logout and redirect to login. + */ + private async handleUnauthorized(): Promise { + try { + const logoutPath = '/api/v0/auth/logout'; + const request: RequestInit = { + method: 'POST', + body: null, + }; + + request.headers = { + 'Content-Type': 'application/json', + }; + + await fetch(logoutPath, request); + // eslint-disable-next-line no-empty + } catch (error) {} + + setTimeout(() => { + if (!window.location.href.includes('/login')) { + window.location.href = window.location.origin + '/login'; + } + }, 2000); + } }