feat: init rate limiting. functional on passcode/init

This commit is contained in:
Felix Dubrownik
2023-01-20 19:02:36 +01:00
parent a914602b5f
commit 79c07ec0b5
17 changed files with 460 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

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

View 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

View File

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

View File

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

View File

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

View 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
}

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

View 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

View 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:

View File

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

View File

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

View File

@ -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 () => {

View File

@ -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 () => {