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
This commit is contained in:
Vitalii 2022-12-15 14:52:28 +02:00 committed by Storj Robot
parent e598c2b3b1
commit 2ebdc5ff2f
13 changed files with 114 additions and 199 deletions

View File

@ -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"

View File

@ -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")
}

View File

@ -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)

View File

@ -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 {

View File

@ -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();
}

View File

@ -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');
}

View File

@ -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.');
}
}

View File

@ -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');
}

View File

@ -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);
}
}

View File

@ -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');
}

View File

@ -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<string> {
@ -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');
}

View File

@ -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);
}

View File

@ -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<Response> {
return this.sendJSON('DELETE', path, null);
}
/**
* Handles unauthorized actions.
* Call logout and redirect to login.
*/
private async handleUnauthorized(): Promise<void> {
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);
}
}