Skip to content

Commit

Permalink
feat: limit the maximum number of concurrent login attempts (argoproj…
Browse files Browse the repository at this point in the history
…#3467)

* feat: limit the maximum number of concurrent login attempts

* unit test rate limiter

* address reviewer questions
  • Loading branch information
Alexander Matyushentsev authored Apr 23, 2020
1 parent 4ae7013 commit f5b600d
Show file tree
Hide file tree
Showing 15 changed files with 324 additions and 44 deletions.
9 changes: 9 additions & 0 deletions Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Gopkg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,7 @@ required = [
[[constraint]]
name = "github.com/alicebob/miniredis"
version = "2.7.0"

[[constraint]]
name = "github.com/bsm/redislock"
version = "0.4.3"
7 changes: 6 additions & 1 deletion cmd/argocd-server/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"time"

"github.com/argoproj/pkg/stats"
"github.com/go-redis/redis"
"github.com/spf13/cobra"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
Expand All @@ -23,6 +24,7 @@ import (
// NewCommand returns a new instance of an argocd command
func NewCommand() *cobra.Command {
var (
redisClient *redis.Client
insecure bool
listenPort int
metricsPort int
Expand Down Expand Up @@ -78,6 +80,7 @@ func NewCommand() *cobra.Command {
TLSConfigCustomizer: tlsConfigCustomizer,
Cache: cache,
XFrameOptions: frameOptions,
RedisClient: redisClient,
}

stats.RegisterStackDumper()
Expand Down Expand Up @@ -109,6 +112,8 @@ func NewCommand() *cobra.Command {
command.Flags().IntVar(&repoServerTimeoutSeconds, "repo-server-timeout-seconds", 60, "Repo server RPC call timeout seconds.")
command.Flags().StringVar(&frameOptions, "x-frame-options", "sameorigin", "Set X-Frame-Options header in HTTP responses to `value`. To disable, set to \"\".")
tlsConfigCustomizerSrc = tls.AddTLSFlagsToCmd(command)
cacheSrc = servercache.AddCacheFlagsToCmd(command)
cacheSrc = servercache.AddCacheFlagsToCmd(command, func(client *redis.Client) {
redisClient = client
})
return command
}
3 changes: 3 additions & 0 deletions docs/operator-manual/user-management/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ regardless of the time frame they happened.
* `ARGOCD_SESSION_MAX_CACHE_SIZE`: Maximum number of entries allowed in the
cache. Default: 1000

* `ARGOCD_MAX_CONCURRENT_LOGIN_REQUESTS_COUNT`: Limits max number of concurrent login requests.
If set to 0 then limit is disabled. Default: 50.

## SSO

There are two ways that SSO can be configured:
Expand Down
2 changes: 1 addition & 1 deletion server/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func newTestAccountServerExt(ctx context.Context, enforceFn rbac.ClaimsEnforcerF
enforcer := rbac.NewEnforcer(kubeclientset, testNamespace, common.ArgoCDRBACConfigMapName, nil)
enforcer.SetClaimsEnforcerFunc(enforceFn)

return NewServer(sessionMgr, settingsMgr, enforcer), session.NewServer(sessionMgr, nil)
return NewServer(sessionMgr, settingsMgr, enforcer), session.NewServer(sessionMgr, nil, nil)
}

func getAdminAccount(mgr *settings.SettingsManager) (*settings.Account, error) {
Expand Down
5 changes: 3 additions & 2 deletions server/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"time"

"github.com/go-redis/redis"
"github.com/spf13/cobra"

appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
Expand Down Expand Up @@ -36,7 +37,7 @@ type ClusterInfo struct {
Version string
}

func AddCacheFlagsToCmd(cmd *cobra.Command) func() (*Cache, error) {
func AddCacheFlagsToCmd(cmd *cobra.Command, opts ...func(client *redis.Client)) func() (*Cache, error) {
var connectionStatusCacheExpiration time.Duration
var oidcCacheExpiration time.Duration
var loginAttemptsExpiration time.Duration
Expand All @@ -45,7 +46,7 @@ func AddCacheFlagsToCmd(cmd *cobra.Command) func() (*Cache, error) {
cmd.Flags().DurationVar(&oidcCacheExpiration, "oidc-cache-expiration", 3*time.Minute, "Cache expiration for OIDC state")
cmd.Flags().DurationVar(&loginAttemptsExpiration, "login-attempts-expiration", 24*time.Hour, "Cache expiration for failed login attempts")

fn := appstatecache.AddCacheFlagsToCmd(cmd)
fn := appstatecache.AddCacheFlagsToCmd(cmd, opts...)

return func() (*Cache, error) {
cache, err := fn()
Expand Down
21 changes: 20 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/tls"
"fmt"
"io/ioutil"
"math"
"net"
"net/http"
"net/url"
Expand All @@ -16,6 +17,7 @@ import (
"time"

"github.com/dgrijalva/jwt-go"
"github.com/go-redis/redis"
golang_proto "github.com/golang/protobuf/proto"
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
grpc_auth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
Expand Down Expand Up @@ -77,6 +79,7 @@ import (
"github.com/argoproj/argo-cd/util/db"
"github.com/argoproj/argo-cd/util/dex"
dexutil "github.com/argoproj/argo-cd/util/dex"
"github.com/argoproj/argo-cd/util/env"
grpc_util "github.com/argoproj/argo-cd/util/grpc"
"github.com/argoproj/argo-cd/util/healthz"
httputil "github.com/argoproj/argo-cd/util/http"
Expand All @@ -92,6 +95,10 @@ import (
"github.com/argoproj/argo-cd/util/webhook"
)

const (
maxConcurrentLoginRequestsCountEnv = "ARGOCD_MAX_CONCURRENT_LOGIN_REQUESTS_COUNT"
)

var (
// ErrNoSession indicates no auth token was supplied as part of a request
ErrNoSession = status.Errorf(codes.Unauthenticated, "no session information")
Expand All @@ -114,8 +121,14 @@ var backoff = wait.Backoff{
var (
clientConstraint = fmt.Sprintf(">= %s", common.MinClientVersion)
baseHRefRegex = regexp.MustCompile(`<base href="(.*)">`)
// limits number of concurrent login requests to prevent password brute forcing. If set to 0 then no limit is enforced.
maxConcurrentLoginRequestsCount = 50
)

func init() {
maxConcurrentLoginRequestsCount = env.ParseNumFromEnv(maxConcurrentLoginRequestsCountEnv, maxConcurrentLoginRequestsCount, 0, math.MaxInt32)
}

// ArgoCDServer is the API server for Argo CD
type ArgoCDServer struct {
ArgoCDServerOpts
Expand Down Expand Up @@ -148,6 +161,7 @@ type ArgoCDServerOpts struct {
AppClientset appclientset.Interface
RepoClientset repoapiclient.Clientset
Cache *servercache.Cache
RedisClient *redis.Client
TLSConfigCustomizer tlsutil.ConfigCustomizer
XFrameOptions string
}
Expand Down Expand Up @@ -470,7 +484,12 @@ func (a *ArgoCDServer) newGRPCServer() *grpc.Server {
clusterService := cluster.NewServer(db, a.enf, a.Cache, kubectl)
repoService := repository.NewServer(a.RepoClientset, db, a.enf, a.Cache, a.settingsMgr)
repoCredsService := repocreds.NewServer(a.RepoClientset, db, a.enf, a.settingsMgr)
sessionService := session.NewServer(a.sessionMgr, a)
var loginRateLimiter func() (util.Closer, error)
if maxConcurrentLoginRequestsCount > 0 {
loginRateLimiter = session.NewLoginRateLimiter(
session.NewRedisStateStorage(a.ArgoCDServerOpts.RedisClient), maxConcurrentLoginRequestsCount)
}
sessionService := session.NewServer(a.sessionMgr, a, loginRateLimiter)
projectLock := util.NewKeyLock()
applicationService := application.NewServer(a.Namespace, a.KubeClientset, a.AppClientset, a.appLister, a.RepoClientset, a.Cache, kubectl, db, a.enf, projectLock, a.settingsMgr)
projectService := project.NewServer(a.Namespace, a.KubeClientset, a.AppClientset, a.enf, projectLock, a.sessionMgr)
Expand Down
94 changes: 94 additions & 0 deletions server/session/ratelimiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package session

import (
"fmt"
"io"
"time"

"github.com/argoproj/argo-cd/util"
"github.com/argoproj/argo-cd/util/session"

"github.com/bsm/redislock"
"github.com/go-redis/redis"
log "github.com/sirupsen/logrus"
)

const (
lockKey = "login:lock"
inProgressCountKey = "login:in-progress-count"
inProgressTimeoutDelay = time.Minute
)

type stateStorage interface {
obtainLock(key string, ttl time.Duration) (io.Closer, error)
set(key string, value interface{}, expiration time.Duration) error
get(key string) (int, error)
}

// NewRedisStateStorage creates storage which leverages redis to establish distributed lock and store number
// of incomplete login requests.
func NewRedisStateStorage(client *redis.Client) *redisStateStorage {
return &redisStateStorage{client: client, locker: redislock.New(client)}
}

type redisStateStorage struct {
client *redis.Client
locker *redislock.Client
}

func (redis *redisStateStorage) obtainLock(key string, ttl time.Duration) (io.Closer, error) {
lock, err := redis.locker.Obtain(key, ttl, nil)
if err != nil {
return nil, err
}
return util.NewCloser(lock.Release), nil
}

func (redis *redisStateStorage) set(key string, value interface{}, expiration time.Duration) error {
return redis.client.Set(key, value, expiration).Err()
}

func (redis *redisStateStorage) get(key string) (int, error) {
return redis.client.Get(key).Int()
}

// NewLoginRateLimiter creates a function which enforces max number of concurrent login requests.
// Function returns closer that should be closed when loging request has completed or error if number
// of incomplete requests exceeded max number.
func NewLoginRateLimiter(storage stateStorage, maxNumber int) func() (util.Closer, error) {
runLocked := func(callback func() error) error {
closer, err := storage.obtainLock(lockKey, 100*time.Millisecond)
if err != nil {
return fmt.Errorf("failed to enforce max concurrent logins limit: %v", err)
}
defer func() {
if err = closer.Close(); err != nil {
log.Warnf("failed to release redis lock: %v", err)
}
}()
return callback()
}

return func() (util.Closer, error) {
if err := runLocked(func() error {
inProgressCount, err := storage.get(inProgressCountKey)
if err != nil && err != redis.Nil {
return err
}
if inProgressCount = inProgressCount + 1; inProgressCount > maxNumber {
log.Warnf("Exceeded number of concurrent login requests")
return session.InvalidLoginErr
}
return storage.set(inProgressCountKey, inProgressCount, inProgressTimeoutDelay)
}); err != nil {
return nil, err
}
return util.NewCloser(func() error {
inProgressCount, err := storage.get(inProgressCountKey)
if err != nil && err != redis.Nil {
return err
}
return storage.set(inProgressCountKey, inProgressCount-1, inProgressTimeoutDelay)
}), nil
}
}
55 changes: 55 additions & 0 deletions server/session/ratelimiter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package session

import (
"io"
"testing"
"time"

"github.com/stretchr/testify/assert"

"github.com/argoproj/argo-cd/util"
"github.com/argoproj/argo-cd/util/session"
)

type fakeStorage struct {
locked bool
values map[string]int
}

func (f *fakeStorage) obtainLock(key string, ttl time.Duration) (io.Closer, error) {
return util.NopCloser, nil
}

func (f *fakeStorage) set(key string, value interface{}, expiration time.Duration) error {
f.values[key] = value.(int)
return nil
}

func (f *fakeStorage) get(key string) (int, error) {
return f.values[key], nil
}

func newFakeStorage() *fakeStorage {
return &fakeStorage{values: map[string]int{}}
}

func TestRateLimiter(t *testing.T) {
var closers []util.Closer
limiter := NewLoginRateLimiter(newFakeStorage(), 10)
for i := 0; i < 10; i++ {
closer, err := limiter()
assert.NoError(t, err)
closers = append(closers, closer)
}
// 11 request should fail
_, err := limiter()
assert.Equal(t, err, session.InvalidLoginErr)

if !assert.Equal(t, len(closers), 10) {
return
}
// complete one request
assert.NoError(t, closers[0].Close())
_, err = limiter()
assert.NoError(t, err)
}
18 changes: 14 additions & 4 deletions server/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,37 @@ import (
"google.golang.org/grpc/status"

"github.com/argoproj/argo-cd/pkg/apiclient/session"
"github.com/argoproj/argo-cd/util"
sessionmgr "github.com/argoproj/argo-cd/util/session"
)

// Server provides a Session service
type Server struct {
mgr *sessionmgr.SessionManager
authenticator Authenticator
mgr *sessionmgr.SessionManager
authenticator Authenticator
limitLoginAttempts func() (util.Closer, error)
}

type Authenticator interface {
Authenticate(ctx context.Context) (context.Context, error)
}

// NewServer returns a new instance of the Session service
func NewServer(mgr *sessionmgr.SessionManager, authenticator Authenticator) *Server {
return &Server{mgr, authenticator}
func NewServer(mgr *sessionmgr.SessionManager, authenticator Authenticator, rateLimiter func() (util.Closer, error)) *Server {
return &Server{mgr, authenticator, rateLimiter}
}

// Create generates a JWT token signed by Argo CD intended for web/CLI logins of the admin user
// using username/password
func (s *Server) Create(_ context.Context, q *session.SessionCreateRequest) (*session.SessionResponse, error) {
if s.limitLoginAttempts != nil {
closer, err := s.limitLoginAttempts()
if err != nil {
return nil, err
}
defer util.Close(closer)
}

if q.Token != "" {
return nil, status.Errorf(codes.Unauthenticated, "token-based session creation no longer supported. please upgrade argocd cli to v0.7+")
}
Expand Down
5 changes: 3 additions & 2 deletions util/cache/appstate/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"time"

"github.com/go-redis/redis"
"github.com/spf13/cobra"

appv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
Expand All @@ -21,12 +22,12 @@ func NewCache(cache *cacheutil.Cache, appStateCacheExpiration time.Duration) *Ca
return &Cache{cache, appStateCacheExpiration}
}

func AddCacheFlagsToCmd(cmd *cobra.Command) func() (*Cache, error) {
func AddCacheFlagsToCmd(cmd *cobra.Command, opts ...func(client *redis.Client)) func() (*Cache, error) {
var appStateCacheExpiration time.Duration

cmd.Flags().DurationVar(&appStateCacheExpiration, "app-state-cache-expiration", 1*time.Hour, "Cache expiration for app state")

cacheFactory := cacheutil.AddCacheFlagsToCmd(cmd)
cacheFactory := cacheutil.AddCacheFlagsToCmd(cmd, opts...)

return func() (*Cache, error) {
cache, err := cacheFactory()
Expand Down
Loading

0 comments on commit f5b600d

Please sign in to comment.