Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add per-organization rate limits #3231

Merged
merged 13 commits into from
Sep 26, 2022
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ require (
require (
github.com/Masterminds/semver/v3 v3.1.1
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
github.com/alicebob/miniredis/v2 v2.23.0
github.com/coreos/go-oidc/v3 v3.4.0
github.com/creack/pty v1.1.18
github.com/getkin/kin-openapi v0.103.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-redis/redis_rate/v9 v9.1.2
github.com/google/go-cmp v0.5.9
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
github.com/iancoleman/strcase v0.2.0
Expand All @@ -58,7 +61,9 @@ require github.com/invopop/yaml v0.1.0 // indirect

require (
cloud.google.com/go/compute v1.7.0 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
Expand All @@ -73,6 +78,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/tklauser/go-sysconf v0.3.10 // indirect
github.com/tklauser/numcpus v0.4.0 // indirect
github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect
go.opencensus.io v0.23.0 // indirect
)
Expand Down
17 changes: 17 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.23.0 h1:+lwAJYjvvdIVg6doFHuotFjueJ/7KY10xo/vm3X3Scw=
github.com/alicebob/miniredis/v2 v2.23.0/go.mod h1:XNqvJdQJv5mSuVMc0ynneafpnL/zv52acZ6kqeS0t88=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-metrics v0.4.0 h1:yCQqn7dwca4ITXb+CbubHmedzaQYHhNhrEXLYUeEe8Q=
github.com/armon/go-metrics v0.4.0/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
Expand Down Expand Up @@ -136,6 +140,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
github.com/docker/docker v20.10.14+incompatible h1:+T9/PRYWNDo5SZl5qS1r9Mo/0Q8AwxKKPtu9S1yxM0w=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
Expand All @@ -161,6 +167,7 @@ github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYF
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0=
github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.1.4 h1:6Bubmk3vZvnL9umQ9qTV2kwNQnjaZ4HLAbxR+xR3ATg=
Expand Down Expand Up @@ -204,6 +211,10 @@ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw=
github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-redis/redis_rate/v9 v9.1.2 h1:H0l5VzoAtOE6ydd38j8MCq3ABlGLnvvbA1xDSVVCHgQ=
github.com/go-redis/redis_rate/v9 v9.1.2/go.mod h1:oam2de2apSgRG8aJzwJddXbNu91Iyz1m8IKJE2vpvlQ=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/goccy/go-json v0.9.0 h1:2flW7bkbrRgU8VuDi0WXDqTmPimjv1thfxkPe8sug+8=
Expand Down Expand Up @@ -549,8 +560,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo/v2 v2.1.6 h1:Fx2POJZfKRQcM1pH49qSZiYeu319wji004qX+GDovrU=
github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
Expand Down Expand Up @@ -671,6 +684,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9 h1:k/gmLsJDWwWqbLCur2yWnJzwQEKRcAHXo6seXGuSwWw=
github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA=
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
Expand Down Expand Up @@ -838,6 +853,7 @@ golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand Down Expand Up @@ -1203,6 +1219,7 @@ gopkg.in/segmentio/analytics-go.v3 v3.1.0 h1:UzxH1uaGZRpMKDhJyBz0pexz6yUoBU3x8bJ
gopkg.in/segmentio/analytics-go.v3 v3.1.0/go.mod h1:4QqqlTlSSpVlWA9/9nDcPw+FkM2yv1NQoYjUbL9/JAw=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand Down
5 changes: 5 additions & 0 deletions internal/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/infrahq/infra/internal/logging"
"github.com/infrahq/infra/internal/server"
"github.com/infrahq/infra/internal/server/data"
"github.com/infrahq/infra/internal/server/redis"
)

func newServerCmd() *cobra.Command {
Expand Down Expand Up @@ -106,6 +107,10 @@ func defaultServerOptions(infraDir string) server.Options {
MaxIdleConnections: 100,
MaxIdleTimeout: 5 * time.Minute,
},

Redis: redis.Options{
Port: 6379,
},
}
}

Expand Down
13 changes: 13 additions & 0 deletions internal/cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/infrahq/infra/internal/cmd/types"
"github.com/infrahq/infra/internal/server"
"github.com/infrahq/infra/internal/server/data"
"github.com/infrahq/infra/internal/server/redis"
"github.com/infrahq/infra/internal/testing/database"
)

Expand Down Expand Up @@ -186,6 +187,11 @@ users:
- name: username
accessKey: access-key
password: the-password

redis:
host: myredis
username: myuser
password: mypassword
`

dir := fs.NewDir(t, t.Name(),
Expand Down Expand Up @@ -284,6 +290,13 @@ users:
MaxIdleConnections: 100,
MaxIdleTimeout: 5 * time.Minute,
},

Redis: redis.Options{
Host: "myredis",
Port: 6379,
Username: "myuser",
Password: "mypassword",
},
}
},
},
Expand Down
8 changes: 4 additions & 4 deletions internal/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ var (
// ErrBadGateway means an invalid response was received from an upstream server (probably an OIDC provider)
ErrBadGateway = fmt.Errorf("bad gateway")

ErrNotFound = fmt.Errorf("record not found")
ErrBadRequest = fmt.Errorf("bad request")
ErrNotImplemented = fmt.Errorf("not implemented")
ErrExpired = fmt.Errorf("expired")
ErrNotFound = fmt.Errorf("record not found")
ErrBadRequest = fmt.Errorf("bad request")
ErrNotImplemented = fmt.Errorf("not implemented")
ErrExpired = fmt.Errorf("expired")
)
8 changes: 8 additions & 0 deletions internal/server/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"

"github.com/gin-gonic/gin"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/infrahq/infra/internal/access"
"github.com/infrahq/infra/internal/logging"
"github.com/infrahq/infra/internal/server/data"
"github.com/infrahq/infra/internal/server/redis"
"github.com/infrahq/infra/internal/validate"
)

Expand All @@ -30,6 +32,7 @@ func sendAPIError(c *gin.Context, err error) {
var validationError validate.Error
var uniqueConstraintError data.UniqueConstraintError
var authzError access.AuthorizationError
var overLimitError redis.OverLimitError

log := logging.L.Debug()

Expand Down Expand Up @@ -97,6 +100,11 @@ func sendAPIError(c *gin.Context, err error) {
resp.Code = http.StatusBadGateway
resp.Message = err.Error()

case errors.As(err, &overLimitError):
c.Writer.Header().Set("Retry-After", strconv.Itoa(int(overLimitError.RetryAfter.Seconds())))
resp.Code = http.StatusTooManyRequests
resp.Message = err.Error()

case errors.Is(err, context.DeadlineExceeded):
resp.Code = http.StatusGatewayTimeout // not ideal, but StatusRequestTimeout isn't intended for this.
resp.Message = "request timed out"
Expand Down
5 changes: 5 additions & 0 deletions internal/server/forgotdomain.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ import (
"github.com/infrahq/infra/internal"
"github.com/infrahq/infra/internal/access"
"github.com/infrahq/infra/internal/server/email"
"github.com/infrahq/infra/internal/server/redis"
)

func (a *API) RequestForgotDomains(c *gin.Context, r *api.ForgotDomainRequest) (*api.EmptyResponse, error) {
if err := redis.NewLimiter(a.server.redis).RateOK(r.Email, 10); err != nil {
return nil, err
}

domains, err := access.ForgotDomainRequest(c, r.Email)

if err != nil {
Expand Down
29 changes: 29 additions & 0 deletions internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/infrahq/infra/internal/server/email"
"github.com/infrahq/infra/internal/server/models"
"github.com/infrahq/infra/internal/server/providers"
"github.com/infrahq/infra/internal/server/redis"
)

type API struct {
Expand Down Expand Up @@ -132,11 +133,31 @@ func wrapLinkWithVerification(link, domain, verificationToken string) string {
func (a *API) Login(c *gin.Context, r *api.LoginRequest) (*api.LoginResponse, error) {
rCtx := getRequestContext(c)

var onSuccess, onFailure func()

var loginMethod authn.LoginMethod
switch {
case r.AccessKey != "":
loginMethod = authn.NewKeyExchangeAuthentication(r.AccessKey)
case r.PasswordCredentials != nil:
if err := redis.NewLimiter(a.server.redis).RateOK(r.PasswordCredentials.Name, 10); err != nil {
return nil, err
}

usernameWithOrganization := fmt.Sprintf("%s:%s", r.PasswordCredentials.Name, rCtx.Authenticated.Organization.ID)
limiter := redis.NewLimiter(a.server.redis)
if err := limiter.LoginOK(usernameWithOrganization); err != nil {
return nil, err
}

onSuccess = func() {
limiter.LoginGood(usernameWithOrganization)
}

onFailure = func() {
limiter.LoginBad(usernameWithOrganization, 10)
}

loginMethod = authn.NewPasswordCredentialAuthentication(r.PasswordCredentials.Name, r.PasswordCredentials.Password)
case r.OIDC != nil:
provider, err := access.GetProvider(c, r.OIDC.ProviderID)
Expand All @@ -159,6 +180,10 @@ func (a *API) Login(c *gin.Context, r *api.LoginRequest) (*api.LoginResponse, er
expires := time.Now().UTC().Add(a.server.options.SessionDuration)
result, err := authn.Login(rCtx.Request.Context(), rCtx.DBTxn, loginMethod, expires, a.server.options.SessionExtensionDeadline)
if err != nil {
if onFailure != nil {
onFailure()
}

if errors.Is(err, internal.ErrBadGateway) {
// the user should be shown this explicitly
// this means an external request failed, probably to an IDP
Expand All @@ -168,6 +193,10 @@ func (a *API) Login(c *gin.Context, r *api.LoginRequest) (*api.LoginResponse, er
return nil, fmt.Errorf("%w: login failed: %v", internal.ErrUnauthorized, err)
}

if onSuccess != nil {
onSuccess()
}

cookie := cookieConfig{
Name: cookieAuthorizationName,
Value: result.Bearer,
Expand Down
5 changes: 4 additions & 1 deletion internal/server/passwordreset.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import (
"github.com/infrahq/infra/internal"
"github.com/infrahq/infra/internal/access"
"github.com/infrahq/infra/internal/server/email"
"github.com/infrahq/infra/internal/server/redis"
)

func (a *API) RequestPasswordReset(c *gin.Context, r *api.PasswordResetRequest) (*api.EmptyResponse, error) {
// TODO: rate-limit
if err := redis.NewLimiter(a.server.redis).RateOK(r.Email, 10); err != nil {
return nil, err
}

token, user, err := access.PasswordResetRequest(c, r.Email, 15*time.Minute)
if err != nil {
Expand Down
Loading