private/web,satellite/console/.../consoleapi: serve rate limiting errors as JSON

This change causes rate limiting errors to be returned to the client
as JSON objects rather than plain text to prevent the satellite UI from
encountering issues when trying to parse them.

Resolves storj/customer-issues#88

Change-Id: I11abd19068927a22f1c28d18fc99e7dad8461834
This commit is contained in:
Jeremy Wharton 2022-11-21 12:58:42 -06:00 committed by Storj Robot
parent f0ce8996c3
commit 7a2be3e6f6
14 changed files with 94 additions and 67 deletions

45
private/web/error.go Normal file
View File

@ -0,0 +1,45 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package web
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
)
// ServeJSONError writes a JSON error to the response output stream.
func ServeJSONError(log *zap.Logger, w http.ResponseWriter, status int, err error) {
ServeCustomJSONError(log, w, status, err, err.Error())
}
// ServeCustomJSONError writes a JSON error with a custom message to the response output stream.
func ServeCustomJSONError(log *zap.Logger, w http.ResponseWriter, status int, err error, msg string) {
fields := []zap.Field{
zap.Int("code", status),
zap.String("message", msg),
zap.Error(err),
}
switch status {
case http.StatusNoContent:
return
case http.StatusInternalServerError:
log.Error("returning error to client", fields...)
case http.StatusBadRequest:
log.Debug("returning error to client", fields...)
case http.StatusTooManyRequests:
default:
log.Info("returning error to client", fields...)
}
w.WriteHeader(status)
err = json.NewEncoder(w).Encode(map[string]string{
"error": msg,
})
if err != nil {
log.Error("failed to write json error response", zap.Error(err))
}
}

View File

@ -11,9 +11,16 @@ import (
"sync"
"time"
"github.com/zeebo/errs"
"go.uber.org/zap"
"golang.org/x/time/rate"
)
const (
internalServerErrMsg = "An internal server error has occurred."
rateLimitErrMsg = "You've exceeded your request limit. Please try again later."
)
// RateLimiterConfig configures a RateLimiter.
type RateLimiterConfig struct {
Duration time.Duration `help:"the rate at which request are allowed" default:"5m"`
@ -24,6 +31,7 @@ type RateLimiterConfig struct {
// RateLimiter imposes a rate limit per key.
type RateLimiter struct {
config RateLimiterConfig
log *zap.Logger
mu sync.Mutex
limits map[string]*userLimit
keyFunc func(*http.Request) (string, error)
@ -36,12 +44,12 @@ type userLimit struct {
}
// NewIPRateLimiter constructs a RateLimiter that limits based on IP address.
func NewIPRateLimiter(config RateLimiterConfig) *RateLimiter {
return NewRateLimiter(config, GetRequestIP)
func NewIPRateLimiter(config RateLimiterConfig, log *zap.Logger) *RateLimiter {
return NewRateLimiter(config, log, GetRequestIP)
}
// NewRateLimiter constructs a RateLimiter.
func NewRateLimiter(config RateLimiterConfig, keyFunc func(*http.Request) (string, error)) *RateLimiter {
func NewRateLimiter(config RateLimiterConfig, log *zap.Logger, keyFunc func(*http.Request) (string, error)) *RateLimiter {
return &RateLimiter{
config: config,
limits: make(map[string]*userLimit),
@ -79,12 +87,12 @@ func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, err := rl.keyFunc(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
ServeCustomJSONError(rl.log, w, http.StatusInternalServerError, err, internalServerErrMsg)
return
}
limit := rl.getUserLimit(key)
if !limit.Allow() {
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
ServeJSONError(rl.log, w, http.StatusTooManyRequests, errs.New(rateLimitErrMsg))
return
}
next.ServeHTTP(w, r)

View File

@ -12,6 +12,7 @@ import (
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"storj.io/common/testcontext"
"storj.io/private/cfgstruct"
@ -23,7 +24,7 @@ func TestNewIPRateLimiter(t *testing.T) {
config := web.RateLimiterConfig{}
cfgstruct.Bind(&pflag.FlagSet{}, &config, cfgstruct.UseDevDefaults())
config.NumLimits = 2
rateLimiter := web.NewIPRateLimiter(config)
rateLimiter := web.NewIPRateLimiter(config, zaptest.NewLogger(t))
// run ratelimiter cleanup until end of test
ctx := testcontext.New(t)

View File

@ -11,6 +11,7 @@ import (
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/private/web"
"storj.io/storj/satellite/abtesting"
"storj.io/storj/satellite/console"
)
@ -40,13 +41,13 @@ func (a *ABTesting) GetABValues(w http.ResponseWriter, r *http.Request) {
user, err := console.GetUser(ctx)
if err != nil {
ServeJSONError(a.log, w, http.StatusUnauthorized, err)
web.ServeJSONError(a.log, w, http.StatusUnauthorized, err)
return
}
values, err := a.service.GetABValues(ctx, *user)
if err != nil {
ServeJSONError(a.log, w, http.StatusInternalServerError, err)
web.ServeJSONError(a.log, w, http.StatusInternalServerError, err)
}
w.Header().Set("Content-Type", "application/json")
@ -65,13 +66,13 @@ func (a *ABTesting) SendHit(w http.ResponseWriter, r *http.Request) {
action := mux.Vars(r)["action"]
if action == "" {
ServeJSONError(a.log, w, http.StatusBadRequest, errs.New("parameter 'action' can't be empty"))
web.ServeJSONError(a.log, w, http.StatusBadRequest, errs.New("parameter 'action' can't be empty"))
return
}
user, err := console.GetUser(ctx)
if err != nil {
ServeJSONError(a.log, w, http.StatusUnauthorized, err)
web.ServeJSONError(a.log, w, http.StatusUnauthorized, err)
return
}

View File

@ -11,6 +11,7 @@ import (
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/private/web"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
)
@ -101,5 +102,5 @@ func (a *Analytics) PageEventTriggered(w http.ResponseWriter, r *http.Request) {
// serveJSONError writes JSON error to response output stream.
func (a *Analytics) serveJSONError(w http.ResponseWriter, status int, err error) {
ServeJSONError(a.log, w, status, err)
web.ServeJSONError(a.log, w, status, err)
}

View File

@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/private/web"
"storj.io/storj/satellite/console"
)
@ -71,5 +72,5 @@ func (keys *APIKeys) DeleteByNameAndProjectID(w http.ResponseWriter, r *http.Req
// serveJSONError writes JSON error to response output stream.
func (keys *APIKeys) serveJSONError(w http.ResponseWriter, status int, err error) {
ServeJSONError(keys.log, w, status, err)
web.ServeJSONError(keys.log, w, status, err)
}

View File

@ -103,7 +103,7 @@ func (a *Auth) Token(w http.ResponseWriter, r *http.Request) {
tokenInfo, err := a.service.Token(ctx, tokenRequest)
if err != nil {
if console.ErrMFAMissing.Has(err) {
serveCustomJSONError(a.log, w, http.StatusOK, err, a.getUserErrorMessage(err))
web.ServeCustomJSONError(a.log, w, http.StatusOK, err, a.getUserErrorMessage(err))
} else {
a.log.Info("Error authenticating token request", zap.String("email", tokenRequest.Email), zap.Error(ErrAuthAPI.Wrap(err)))
a.serveJSONError(w, err)
@ -838,7 +838,7 @@ func (a *Auth) RefreshSession(w http.ResponseWriter, r *http.Request) {
// serveJSONError writes JSON error to response output stream.
func (a *Auth) serveJSONError(w http.ResponseWriter, err error) {
status := a.getStatusCode(err)
serveCustomJSONError(a.log, w, status, err, a.getUserErrorMessage(err))
web.ServeCustomJSONError(a.log, w, status, err, a.getUserErrorMessage(err))
}
// getStatusCode returns http.StatusCode depends on console error class.

View File

@ -11,6 +11,7 @@ import (
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/private/web"
"storj.io/storj/satellite/console"
)
@ -68,5 +69,5 @@ func (b *Buckets) AllBucketNames(w http.ResponseWriter, r *http.Request) {
// serveJSONError writes JSON error to response output stream.
func (b *Buckets) serveJSONError(w http.ResponseWriter, status int, err error) {
ServeJSONError(b.log, w, status, err)
web.ServeJSONError(b.log, w, status, err)
}

View File

@ -5,12 +5,9 @@ package consoleapi
import (
"context"
"encoding/json"
"net/http"
"sync"
"github.com/zeebo/errs"
"go.uber.org/zap"
)
var (
@ -18,39 +15,6 @@ var (
ErrUtils = errs.Class("console api utils")
)
// ServeJSONError writes a JSON error to the response output stream.
func ServeJSONError(log *zap.Logger, w http.ResponseWriter, status int, err error) {
serveCustomJSONError(log, w, status, err, err.Error())
}
// serveCustomJSONError writes a JSON error with a custom message to the response output stream.
func serveCustomJSONError(log *zap.Logger, w http.ResponseWriter, status int, err error, msg string) {
fields := []zap.Field{
zap.Int("code", status),
zap.String("message", msg),
zap.Error(err),
}
switch status {
case http.StatusNoContent:
return
case http.StatusInternalServerError:
log.Error("returning error to client", fields...)
case http.StatusBadRequest:
log.Debug("returning error to client", fields...)
default:
log.Info("returning error to client", fields...)
}
w.WriteHeader(status)
err = json.NewEncoder(w).Encode(map[string]string{
"error": msg,
})
if err != nil {
log.Error("failed to write json error response", zap.Error(ErrUtils.Wrap(err)))
}
}
// ContextChannel is a generic, context-aware channel.
type ContextChannel struct {
mu sync.Mutex

View File

@ -15,6 +15,7 @@ import (
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/storj/private/web"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments/stripecoinpayments"
)
@ -390,5 +391,5 @@ func (p *Payments) WalletPayments(w http.ResponseWriter, r *http.Request) {
// serveJSONError writes JSON error to response output stream.
func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) {
ServeJSONError(p.log, w, status, err)
web.ServeJSONError(p.log, w, status, err)
}

View File

@ -13,6 +13,7 @@ import (
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/private/web"
"storj.io/storj/satellite/console"
)
@ -65,5 +66,5 @@ func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) {
// serveJSONError writes JSON error to response output stream.
func (p *Projects) serveJSONError(w http.ResponseWriter, status int, err error) {
ServeJSONError(p.log, w, status, err)
web.ServeJSONError(p.log, w, status, err)
}

View File

@ -14,6 +14,7 @@ import (
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/private/web"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/console"
)
@ -155,5 +156,5 @@ func (ul *UsageLimits) DailyUsage(w http.ResponseWriter, r *http.Request) {
// serveJSONError writes JSON error to response output stream.
func (ul *UsageLimits) serveJSONError(w http.ResponseWriter, status int, err error) {
ServeJSONError(ul.log, w, status, err)
web.ServeJSONError(ul.log, w, status, err)
}

View File

@ -217,8 +217,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
analytics: analytics,
abTesting: abTesting,
stripePublicKey: stripePublicKey,
ipRateLimiter: web.NewIPRateLimiter(config.RateLimit),
userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit),
ipRateLimiter: web.NewIPRateLimiter(config.RateLimit, logger),
userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit, logger),
nodeURL: nodeURL,
pricing: pricing,
}
@ -548,7 +548,7 @@ func (server *Server) withAuth(handler http.Handler) http.Handler {
defer func() {
if err != nil {
consoleapi.ServeJSONError(server.log, w, http.StatusUnauthorized, console.ErrUnauthorized.Wrap(err))
web.ServeJSONError(server.log, w, http.StatusUnauthorized, console.ErrUnauthorized.Wrap(err))
server.cookieAuth.RemoveTokenCookie(w)
}
}()
@ -991,8 +991,8 @@ func (server *Server) parseTemplates() (_ *templates, err error) {
}
// NewUserIDRateLimiter constructs a RateLimiter that limits based on user ID.
func NewUserIDRateLimiter(config web.RateLimiterConfig) *web.RateLimiter {
return web.NewRateLimiter(config, func(r *http.Request) (string, error) {
func NewUserIDRateLimiter(config web.RateLimiterConfig, log *zap.Logger) *web.RateLimiter {
return web.NewRateLimiter(config, log, func(r *http.Request) (string, error) {
user, err := console.GetUser(r.Context())
if err != nil {
return "", err

View File

@ -15,7 +15,6 @@ import { HttpClient } from '@/utils/httpClient';
export class AuthHttpApi implements UsersApi {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/auth';
private readonly rateLimitErrMsg = 'You\'ve exceeded limit of attempts, try again in 5 minutes';
/**
* Used to resend an registration confirmation email.
@ -30,11 +29,14 @@ export class AuthHttpApi implements UsersApi {
return;
}
if (response.status == 429) {
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
const result = await response.json();
const errMsg = result.error || 'Failed to send email';
switch (response.status) {
case 429:
throw new ErrorTooManyRequests(errMsg);
default:
throw new Error(errMsg);
}
throw new Error('Failed to send email');
}
/**
@ -75,7 +77,7 @@ export class AuthHttpApi implements UsersApi {
case 401:
throw new ErrorUnauthorized(errMsg);
case 429:
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
throw new ErrorTooManyRequests(errMsg);
default:
throw new Error(errMsg);
}
@ -125,7 +127,7 @@ export class AuthHttpApi implements UsersApi {
case 404:
throw new ErrorUnauthorized(errMsg);
case 429:
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
throw new ErrorTooManyRequests(errMsg);
default:
throw new Error(errMsg);
}
@ -279,7 +281,7 @@ export class AuthHttpApi implements UsersApi {
case 401:
throw new ErrorUnauthorized(errMsg);
case 429:
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
throw new ErrorTooManyRequests(errMsg);
default:
throw new Error(errMsg);
}