mirror of
				https://github.com/teamhanko/hanko.git
				synced 2025-10-30 16:16:05 +08:00 
			
		
		
		
	feat: init rate limiting. functional on passcode/init
This commit is contained in:
		| @ -21,6 +21,7 @@ COPY session session/ | ||||
| COPY mail mail/ | ||||
| COPY audit_log audit_log/ | ||||
| COPY pagination pagination/ | ||||
| COPY rate_limiter rate_limiter/ | ||||
|  | ||||
| # Build | ||||
| RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -a -o hanko main.go | ||||
|  | ||||
| @ -22,6 +22,7 @@ COPY session session/ | ||||
| COPY mail mail/ | ||||
| COPY audit_log audit_log/ | ||||
| COPY pagination pagination/ | ||||
| COPY rate_limiter rate_limiter/ | ||||
|  | ||||
| # Build | ||||
| RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -gcflags="all=-N -l" -a -o hanko main.go | ||||
|  | ||||
| @ -389,6 +389,7 @@ To persist audit logs in the database, set `audit_log.storage.enabled` to `true` | ||||
|  | ||||
| ### Rate Limiting | ||||
|  | ||||
| // TODO | ||||
| Currently, Hanko backend does not implement rate limiting in any way. In production systems, you may want to hide the | ||||
| Hanko service behind a proxy or gateway (e.g. Kong, Traefik) that provides rate limiting. | ||||
|  | ||||
|  | ||||
| @ -23,8 +23,22 @@ type Config struct { | ||||
| 	Service     Service          `yaml:"service" json:"service" koanf:"service"` | ||||
| 	Session     Session          `yaml:"session" json:"session" koanf:"session"` | ||||
| 	AuditLog    AuditLog         `yaml:"audit_log" json:"audit_log" koanf:"audit_log"` | ||||
| 	RateLimiter RateLimiter      `yaml:"rate_limiter" json:"rate_limiter" koanf:"rate_limiter"` | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	// HeaderRateLimitLimit, HeaderRateLimitRemaining, and HeaderRateLimitReset | ||||
| 	// are the recommended return header values from IETF on rate limiting. Reset | ||||
| 	// is in UTC time. | ||||
| 	HeaderRateLimitLimit     = "X-RateLimit-Limit" | ||||
| 	HeaderRateLimitRemaining = "X-RateLimit-Remaining" | ||||
| 	HeaderRateLimitReset     = "X-RateLimit-Reset" | ||||
|  | ||||
| 	// HeaderRetryAfter is the header used to indicate when a client should retry | ||||
| 	// requests (when the rate limit expires), in UTC time. | ||||
| 	HeaderRetryAfter = "Retry-After" | ||||
| ) | ||||
|  | ||||
| func Load(cfgFile *string) (*Config, error) { | ||||
| 	k := koanf.New(".") | ||||
| 	var err error | ||||
| @ -58,6 +72,14 @@ func DefaultConfig() *Config { | ||||
| 		Server: Server{ | ||||
| 			Public: ServerSettings{ | ||||
| 				Address: ":8000", | ||||
| 				Cors: Cors{ | ||||
| 					ExposeHeaders: []string{ | ||||
| 						HeaderRateLimitLimit, | ||||
| 						HeaderRateLimitRemaining, | ||||
| 						HeaderRateLimitReset, | ||||
| 						HeaderRetryAfter, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			Admin: ServerSettings{ | ||||
| 				Address: ":8001", | ||||
| @ -67,7 +89,7 @@ func DefaultConfig() *Config { | ||||
| 			RelyingParty: RelyingParty{ | ||||
| 				Id:          "localhost", | ||||
| 				DisplayName: "Hanko Authentication Service", | ||||
| 				Origin:      "http://localhost", | ||||
| 				Origins:     []string{"http://localhost"}, | ||||
| 			}, | ||||
| 			Timeout: 60000, | ||||
| 		}, | ||||
| @ -76,6 +98,10 @@ func DefaultConfig() *Config { | ||||
| 				Port: "465", | ||||
| 			}, | ||||
| 			TTL: 300, | ||||
| 			Email: Email{ | ||||
| 				FromAddress: "passcode@hanko.io", | ||||
| 				FromName:    "Hanko", | ||||
| 			}, | ||||
| 		}, | ||||
| 		Password: Password{ | ||||
| 			MinPasswordLength: 8, | ||||
| @ -97,6 +123,9 @@ func DefaultConfig() *Config { | ||||
| 				OutputStream: OutputStreamStdOut, | ||||
| 			}, | ||||
| 		}, | ||||
| 		RateLimiter: RateLimiter{ | ||||
| 			Enabled: false, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -129,6 +158,10 @@ func (c *Config) Validate() error { | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to validate session settings: %w", err) | ||||
| 	} | ||||
| 	err = c.RateLimiter.Validate() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to validate rate-limiter settings: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| @ -354,3 +387,42 @@ var ( | ||||
| 	OutputStreamStdOut OutputStream = "stdout" | ||||
| 	OutputStreamStdErr OutputStream = "stderr" | ||||
| ) | ||||
|  | ||||
| type RateLimiter struct { | ||||
| 	Enabled  bool                   `yaml:"enabled" json:"enabled" koanf:"enabled"` | ||||
| 	Backend  RateLimiterBackendType `yaml:"backend" json:"backend" koanf:"backend"` | ||||
| 	Redis    *RedisConfig           `yaml:"redis_config" json:"redis_config" koanf:"redis_config"` | ||||
| 	Tokens   *uint64                `yaml:"tokens" json:"tokens" koanf:"tokens"` | ||||
| 	Interval *time.Duration         `yaml:"interval" json:"interval" koanf:"interval"` | ||||
| } | ||||
|  | ||||
| type RateLimiterBackendType string | ||||
|  | ||||
| const ( | ||||
| 	RATE_LIMITER_BACKEND_IN_MEMORY RateLimiterBackendType = "in_memory" | ||||
| 	RATE_LIMITER_BACKEND_REDIS                            = "redis" | ||||
| ) | ||||
|  | ||||
| func (r *RateLimiter) Validate() error { | ||||
| 	if r.Enabled { | ||||
| 		switch r.Backend { | ||||
| 		case RATE_LIMITER_BACKEND_REDIS: | ||||
| 			if r.Redis == nil { | ||||
| 				return errors.New("when enabling the redis backend you have to specify the redis config") | ||||
| 			} | ||||
| 		case RATE_LIMITER_BACKEND_IN_MEMORY: | ||||
| 			break | ||||
| 		default: | ||||
| 			return errors.New(string(r.Backend) + " is not a valid rate limiter backend.") | ||||
| 		} | ||||
| 		if r.Tokens == nil || r.Interval == nil { | ||||
| 			return errors.New("Please specify tokens and interval") | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| type RedisConfig struct { | ||||
| 	Address  string `yaml:"address" json:"address" koanf:"address"` | ||||
| 	Password string `yaml:"password" json:"password" koanf:"password"` | ||||
| } | ||||
|  | ||||
							
								
								
									
										76
									
								
								backend/config/config_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								backend/config/config_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,76 @@ | ||||
| package config | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func TestDefaultConfigNotEnoughForValidation(t *testing.T) { | ||||
| 	cfg := DefaultConfig() | ||||
| 	if err := cfg.Validate(); err == nil { | ||||
| 		t.Error("The default config is missing mandatory parameters. This should not validate without error.") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestParseValidConfig(t *testing.T) { | ||||
| 	configPath := "./config.yaml" | ||||
| 	cfg, err := Load(&configPath) | ||||
| 	if err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
| 	if err := cfg.Validate(); err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestMinimalConfigValidates(t *testing.T) { | ||||
| 	configPath := "./minimal-config.yaml" | ||||
| 	cfg, err := Load(&configPath) | ||||
| 	if err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
| 	if err := cfg.Validate(); err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestRateLimiterConfig(t *testing.T) { | ||||
| 	configPath := "./minimal-config.yaml" | ||||
| 	cfg, err := Load(&configPath) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
| 	var five uint64 = 5 | ||||
| 	aMinute := 1 * time.Minute | ||||
| 	cfg.RateLimiter.Enabled = true | ||||
| 	cfg.RateLimiter.Backend = "in_memory" | ||||
| 	 | ||||
| 	if err := cfg.Validate(); err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
|  | ||||
| 	cfg.RateLimiter.Tokens = &five | ||||
| 	cfg.RateLimiter.Interval = &aMinute | ||||
|  | ||||
| 	if err := cfg.Validate(); err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
|  | ||||
| 	cfg.RateLimiter.Backend = "redis" | ||||
| 	if err := cfg.Validate(); err == nil { | ||||
| 		t.Error("when specifying redis, the redis config should also be specified") | ||||
| 	} | ||||
| 	cfg.RateLimiter.Redis = &RedisConfig{ | ||||
| 		Address:  "127.0.0.1:9876", | ||||
| 		Password: "password", | ||||
| 	} | ||||
| 	if err := cfg.Validate(); err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
|  | ||||
| 	cfg.RateLimiter.Backend = "notvalid" | ||||
| 	if err := cfg.Validate(); err == nil { | ||||
| 		t.Error("notvalid is not a valid backend") | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										12
									
								
								backend/config/minimal-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								backend/config/minimal-config.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| passcode: | ||||
|   smtp: | ||||
|     host: smtp.example.com | ||||
|     user: example | ||||
|     password: example | ||||
| database: | ||||
|   url: postgres://postgres:123456@127.0.0.1:5432/dummy | ||||
| secrets: | ||||
|   keys: | ||||
|     - abcedfghijklmnopqrstuvwxyz | ||||
| service: | ||||
|   name: Hanko Authentication Service | ||||
| @ -8,11 +8,14 @@ require ( | ||||
| 	github.com/gobuffalo/pop/v6 v6.1.0 | ||||
| 	github.com/gobuffalo/validate/v3 v3.3.3 | ||||
| 	github.com/gofrs/uuid v4.3.1+incompatible | ||||
| 	github.com/gomodule/redigo v1.8.2 | ||||
| 	github.com/knadh/koanf v1.4.4 | ||||
| 	github.com/labstack/echo/v4 v4.9.1 | ||||
| 	github.com/lestrrat-go/jwx/v2 v2.0.8 | ||||
| 	github.com/nicksnyder/go-i18n/v2 v2.2.1 | ||||
| 	github.com/rs/zerolog v1.28.0 | ||||
| 	github.com/sethvargo/go-limiter v0.7.2 | ||||
| 	github.com/sethvargo/go-redisstore v0.3.0 | ||||
| 	github.com/spf13/cobra v1.6.1 | ||||
| 	github.com/stretchr/testify v1.8.1 | ||||
| 	golang.org/x/crypto v0.5.0 | ||||
|  | ||||
| @ -160,6 +160,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw | ||||
| github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= | ||||
| github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= | ||||
| github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= | ||||
| github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k= | ||||
| github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= | ||||
| github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= | ||||
| github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= | ||||
| github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= | ||||
| @ -455,6 +457,11 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh | ||||
| github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= | ||||
| github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= | ||||
| github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= | ||||
| github.com/sethvargo/go-limiter v0.6.0/go.mod h1:C0kbSFbiriE5k2FFOe18M1YZbAR2Fiwf72uGu0CXCcU= | ||||
| github.com/sethvargo/go-limiter v0.7.2 h1:FgC4N7RMpV5gMrUdda15FaFTkQ/L4fEqM7seXMs4oO8= | ||||
| github.com/sethvargo/go-limiter v0.7.2/go.mod h1:C0kbSFbiriE5k2FFOe18M1YZbAR2Fiwf72uGu0CXCcU= | ||||
| github.com/sethvargo/go-redisstore v0.3.0 h1:yCDGc7ERWfa9BMgjhMhYcH8k+y85bRx0nziupGhjPkc= | ||||
| github.com/sethvargo/go-redisstore v0.3.0/go.mod h1:rY+FgiPpRrdpi4wETGHdMf6YlJnGiziAt2R8gXaFFxg= | ||||
| github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= | ||||
| github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= | ||||
| github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= | ||||
|  | ||||
| @ -6,6 +6,7 @@ import ( | ||||
| 	"github.com/gobuffalo/pop/v6" | ||||
| 	"github.com/gofrs/uuid" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| 	"github.com/sethvargo/go-limiter" | ||||
| 	"github.com/teamhanko/hanko/backend/audit_log" | ||||
| 	"github.com/teamhanko/hanko/backend/config" | ||||
| 	"github.com/teamhanko/hanko/backend/crypto" | ||||
| @ -13,6 +14,7 @@ import ( | ||||
| 	"github.com/teamhanko/hanko/backend/mail" | ||||
| 	"github.com/teamhanko/hanko/backend/persistence" | ||||
| 	"github.com/teamhanko/hanko/backend/persistence/models" | ||||
| 	"github.com/teamhanko/hanko/backend/rate_limiter" | ||||
| 	"github.com/teamhanko/hanko/backend/session" | ||||
| 	"golang.org/x/crypto/bcrypt" | ||||
| 	"gopkg.in/gomail.v2" | ||||
| @ -31,6 +33,7 @@ type PasscodeHandler struct { | ||||
| 	sessionManager    session.Manager | ||||
| 	cfg               *config.Config | ||||
| 	auditLogger       auditlog.Logger | ||||
| 	rateLimiter       limiter.Store | ||||
| } | ||||
|  | ||||
| var maxPasscodeTries = 3 | ||||
| @ -40,6 +43,10 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create new renderer: %w", err) | ||||
| 	} | ||||
| 	var rateLimiter limiter.Store | ||||
| 	if cfg.RateLimiter.Enabled { | ||||
| 		rateLimiter = rate_limiter.NewRateLimiter(cfg.RateLimiter) | ||||
| 	} | ||||
| 	return &PasscodeHandler{ | ||||
| 		mailer:            mailer, | ||||
| 		renderer:          renderer, | ||||
| @ -51,6 +58,7 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses | ||||
| 		sessionManager:    sessionManager, | ||||
| 		cfg:               cfg, | ||||
| 		auditLogger:       auditLogger, | ||||
| 		rateLimiter:       rateLimiter, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| @ -81,6 +89,13 @@ func (h *PasscodeHandler) Init(c echo.Context) error { | ||||
| 		return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found")) | ||||
| 	} | ||||
|  | ||||
| 	if h.rateLimiter != nil { | ||||
| 		err := rate_limiter.Limit(h.rateLimiter, userId, c) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	passcode, err := h.passcodeGenerator.Generate() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to generate passcode: %w", err) | ||||
|  | ||||
							
								
								
									
										75
									
								
								backend/rate_limiter/rate_limiter.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								backend/rate_limiter/rate_limiter.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| package rate_limiter | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"github.com/gofrs/uuid" | ||||
| 	"github.com/gomodule/redigo/redis" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| 	"github.com/sethvargo/go-limiter" | ||||
| 	"github.com/sethvargo/go-limiter/memorystore" | ||||
| 	"github.com/sethvargo/go-redisstore" | ||||
| 	"github.com/teamhanko/hanko/backend/config" | ||||
| 	"github.com/teamhanko/hanko/backend/dto" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func NewRateLimiter(cfg config.RateLimiter) limiter.Store { | ||||
| 	if cfg.Backend == config.RATE_LIMITER_BACKEND_REDIS { | ||||
| 		//ctx := context.Background() | ||||
| 		store, err := redisstore.New(&redisstore.Config{ | ||||
| 			Tokens:   *cfg.Tokens, | ||||
| 			Interval: *cfg.Interval, | ||||
| 			Dial: func() (redis.Conn, error) { | ||||
| 				return redis.Dial("tcp", cfg.Redis.Address, | ||||
| 					redis.DialPassword(cfg.Redis.Password)) | ||||
| 			}, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			log.Fatal(err) | ||||
| 		} | ||||
| 		//defer store.Close(ctx) | ||||
| 		return store | ||||
| 	} | ||||
| 	// else return in_memory | ||||
| 	store, err := memorystore.New(&memorystore.Config{ | ||||
| 		// Number of tokens allowed per interval. | ||||
| 		Tokens: *cfg.Tokens, | ||||
|  | ||||
| 		// Interval until tokens reset. | ||||
| 		Interval: *cfg.Interval, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| 	return store | ||||
| } | ||||
|  | ||||
| func Limit(store limiter.Store, userId uuid.UUID, c echo.Context) error { | ||||
| 	key := userId.String() + "/" + c.RealIP() | ||||
| 	// Take from the store. | ||||
| 	limit, remaining, reset, ok, err := store.Take(context.Background(), key) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	//resetTime := time.Unix(0, int64(reset)).UTC().Format(time.RFC1123) | ||||
| 	resetTime := int(math.Floor(time.Unix(0, int64(reset)).UTC().Sub(time.Now().UTC()).Seconds())) | ||||
| 	log.Println(resetTime) | ||||
|  | ||||
| 	// Set headers (we do this regardless of whether the request is permitted). | ||||
| 	c.Response().Header().Set(config.HeaderRateLimitLimit, strconv.FormatUint(limit, 10)) | ||||
| 	c.Response().Header().Set(config.HeaderRateLimitRemaining, strconv.FormatUint(remaining, 10)) | ||||
| 	c.Response().Header().Set(config.HeaderRateLimitReset, strconv.Itoa(resetTime)) | ||||
|  | ||||
| 	// Fail if there were no tokens remaining. | ||||
| 	if !ok { | ||||
| 		c.Response().Header().Set(config.HeaderRetryAfter, strconv.Itoa(resetTime)) | ||||
| 		return dto.NewHTTPError(http.StatusTooManyRequests) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										68
									
								
								backend/rate_limiter/rate_limiter_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								backend/rate_limiter/rate_limiter_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,68 @@ | ||||
| package rate_limiter | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"github.com/rs/zerolog/log" | ||||
| 	"github.com/teamhanko/hanko/backend/config" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func TestNewRateLimiter(t *testing.T) { | ||||
| 	aMinute := 1 * time.Minute | ||||
| 	var five uint64 = 5 | ||||
| 	cfg := config.RateLimiter{ | ||||
| 		Enabled:  true, | ||||
| 		Backend:  config.RATE_LIMITER_BACKEND_IN_MEMORY, | ||||
| 		Redis:    nil, | ||||
| 		Tokens:   &five, | ||||
| 		Interval: &aMinute, | ||||
| 	} | ||||
|  | ||||
| 	rl := NewRateLimiter(cfg) | ||||
| 	// Take 5 tokens: should be good. | ||||
| 	for i := 0; i < 5; i++ { | ||||
| 		tokens, remaining, reset, ok, e := rl.Take(context.Background(), "some-key") | ||||
| 		log.Printf("Tokens: %v, Remaining: %v, Reset: %v, ok: %v, error: %v\n", tokens, remaining, time.Unix(0, int64(reset)).String(), ok, e) | ||||
| 		if e != nil { | ||||
| 			t.Error(e) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	tokens, remaining, reset, ok, e := rl.Take(context.Background(), "some-key") | ||||
| 	log.Printf("Tokens: %v, Remaining: %v, Reset: %v, ok: %v, error: %v\n", tokens, remaining, time.Unix(0, int64(reset)).String(), ok, e) | ||||
| 	if ok { | ||||
| 		t.Error("Taking a token should fail at this point") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNewRateLimiterRedis(t *testing.T) { | ||||
| 	aMinute := 1 * time.Minute | ||||
| 	var five uint64 = 5 | ||||
| 	cfg := config.RateLimiter{ | ||||
| 		Enabled: true, | ||||
| 		Backend: config.RATE_LIMITER_BACKEND_REDIS, | ||||
| 		Redis: &config.RedisConfig{ | ||||
| 			Address:  "localhost:6379", | ||||
| 			Password: "", | ||||
| 		}, | ||||
| 		Tokens:   &five, | ||||
| 		Interval: &aMinute, | ||||
| 	} | ||||
|  | ||||
| 	rl := NewRateLimiter(cfg) | ||||
| 	// Take 5 tokens: should be good. | ||||
| 	for i := 0; i < 6; i++ { | ||||
| 		tokens, remaining, e := rl.Get(context.Background(), "some-key") | ||||
| 		log.Printf("Tokens: %v, Remaining: %v\n", tokens, remaining) | ||||
| 		if e != nil { | ||||
| 			t.Error(e) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Try to take the sixth token, should faild | ||||
| 	_, _, e := rl.Get(context.Background(), "some-key") | ||||
| 	if e == nil { | ||||
| 		t.Error("Taking a token should fail at this point") | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										35
									
								
								deploy/docker-compose/config-rate-limiting.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								deploy/docker-compose/config-rate-limiting.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| database: | ||||
|   user: hanko | ||||
|   password: hanko | ||||
|   host: postgresd | ||||
|   port: 5432 | ||||
|   dialect: postgres | ||||
| passcode: | ||||
|   email: | ||||
|     from_address: no-reply@hanko.io | ||||
|   smtp: | ||||
|     host: "mailslurper" | ||||
|     port: "2500" | ||||
| secrets: | ||||
|   keys: | ||||
|     - abcedfghijklmnopqrstuvwxyz | ||||
| service: | ||||
|   name: Hanko Authentication Service | ||||
| server: | ||||
|   public: | ||||
|     cors: | ||||
|       enabled: true | ||||
|       allow_credentials: true | ||||
|       allow_origins: | ||||
|         - "*" | ||||
| webauthn: | ||||
|   relying_party: | ||||
|     origin: "http://localhost:8888" | ||||
| session: | ||||
|   cookie: | ||||
|     secure: false # is needed for safari, because safari does not store secure cookies on localhost | ||||
| rate_limiter: | ||||
|   enabled: true | ||||
|   backend: "in_memory" | ||||
|   tokens: 2 | ||||
|   interval: 1m | ||||
							
								
								
									
										80
									
								
								deploy/docker-compose/quickstart-with-redis.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								deploy/docker-compose/quickstart-with-redis.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| services: | ||||
|   hanko-migrate: | ||||
|     build: ../../backend | ||||
|     volumes: | ||||
|       - type: bind | ||||
|         source: ./config.yaml | ||||
|         target: /etc/config/config.yaml | ||||
|     command: --config /etc/config/config.yaml migrate up | ||||
|     restart: on-failure | ||||
|     depends_on: | ||||
|       postgresd: | ||||
|         condition: service_healthy | ||||
|     networks: | ||||
|       - intranet | ||||
|   hanko: | ||||
|     depends_on: | ||||
|       hanko-migrate: | ||||
|         condition: service_completed_successfully | ||||
|     build: ../../backend | ||||
|     ports: | ||||
|       - '8000:8000' # public | ||||
|       - '8001:8001' # admin | ||||
|     restart: unless-stopped | ||||
|     command: serve --config /etc/config/config.yaml all | ||||
|     volumes: | ||||
|       - type: bind | ||||
|         source: ./config-rate-limiting.yaml | ||||
|         target: /etc/config/config.yaml | ||||
|     networks: | ||||
|       - intranet | ||||
|     environment: | ||||
|       - PASSWORD_ENABLED | ||||
|   postgresd: | ||||
|     image: postgres:12-alpine | ||||
|     ports: | ||||
|       - "5432:5432" | ||||
|     environment: | ||||
|       - POSTGRES_USER=hanko | ||||
|       - POSTGRES_PASSWORD=hanko | ||||
|       - POSTGRES_DB=hanko | ||||
|     healthcheck: | ||||
|       test: pg_isready -U hanko -d hanko | ||||
|       interval: 10s | ||||
|       timeout: 10s | ||||
|       retries: 3 | ||||
|       start_period: 30s | ||||
|     networks: | ||||
|       - intranet | ||||
|   elements: | ||||
|     build: ../../frontend | ||||
|     ports: | ||||
|       - "9500:80" | ||||
|     networks: | ||||
|       - intranet | ||||
|   quickstart: | ||||
|     build: ../../quickstart | ||||
|     ports: | ||||
|       - "8888:8080" | ||||
|     environment: | ||||
|       - HANKO_URL=http://localhost:8000 | ||||
|       - HANKO_URL_INTERNAL=http://hanko:8000 | ||||
|       - HANKO_ELEMENT_URL=http://localhost:9500/element.hanko-auth.js | ||||
|       - HANKO_FRONTEND_SDK_URL=http://localhost:9500/sdk.umd.js | ||||
|     networks: | ||||
|       - intranet | ||||
|   mailslurper: | ||||
|     image: marcopas/docker-mailslurper:latest | ||||
|     ports: | ||||
|       - '8080:8080' # web UI | ||||
|       - '8085:8085' | ||||
|     networks: | ||||
|       - intranet | ||||
|   redis: | ||||
|     image: redis:7-alpine | ||||
|     ports: | ||||
|       - "6379:6379" | ||||
|     networks: | ||||
|       - intranet | ||||
| networks: | ||||
|   intranet: | ||||
| @ -46,7 +46,7 @@ class PasscodeClient extends Client { | ||||
|  | ||||
|     if (response.status === 429) { | ||||
|       const retryAfter = parseInt( | ||||
|         response.headers.get("X-Retry-After") || "0", | ||||
|         response.headers.get("Retry-After") || "0", | ||||
|         10 | ||||
|       ); | ||||
|  | ||||
|  | ||||
| @ -48,7 +48,7 @@ class PasswordClient extends Client { | ||||
|       throw new InvalidPasswordError(); | ||||
|     } else if (response.status === 429) { | ||||
|       const retryAfter = parseInt( | ||||
|         response.headers.get("X-Retry-After") || "0", | ||||
|         response.headers.get("Retry-After") || "0", | ||||
|         10 | ||||
|       ); | ||||
|  | ||||
|  | ||||
| @ -74,7 +74,7 @@ describe("PasscodeClient.initialize()", () => { | ||||
|       passcodeRetryAfter | ||||
|     ); | ||||
|     expect(passcodeClient.state.write).toHaveBeenCalledTimes(1); | ||||
|     expect(response.headers.get).toHaveBeenCalledWith("X-Retry-After"); | ||||
|     expect(response.headers.get).toHaveBeenCalledWith("Retry-After"); | ||||
|   }); | ||||
|  | ||||
|   it("should throw error when API response is not ok", async () => { | ||||
|  | ||||
| @ -63,7 +63,7 @@ describe("PasswordClient.login()", () => { | ||||
|       passwordRetryAfter | ||||
|     ); | ||||
|     expect(passwordClient.state.write).toHaveBeenCalledTimes(1); | ||||
|     expect(response.headers.get).toHaveBeenCalledWith("X-Retry-After"); | ||||
|     expect(response.headers.get).toHaveBeenCalledWith("Retry-After"); | ||||
|   }); | ||||
|  | ||||
|   it("should throw error when API response is not ok", async () => { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Felix Dubrownik
					Felix Dubrownik