mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-29 15:49:41 +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 mail mail/
|
||||||
COPY audit_log audit_log/
|
COPY audit_log audit_log/
|
||||||
COPY pagination pagination/
|
COPY pagination pagination/
|
||||||
|
COPY rate_limiter rate_limiter/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -a -o hanko main.go
|
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 mail mail/
|
||||||
COPY audit_log audit_log/
|
COPY audit_log audit_log/
|
||||||
COPY pagination pagination/
|
COPY pagination pagination/
|
||||||
|
COPY rate_limiter rate_limiter/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -gcflags="all=-N -l" -a -o hanko main.go
|
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
|
### Rate Limiting
|
||||||
|
|
||||||
|
// TODO
|
||||||
Currently, Hanko backend does not implement rate limiting in any way. In production systems, you may want to hide the
|
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.
|
Hanko service behind a proxy or gateway (e.g. Kong, Traefik) that provides rate limiting.
|
||||||
|
|
||||||
|
|||||||
@ -14,17 +14,31 @@ import (
|
|||||||
|
|
||||||
// Config is the central configuration type
|
// Config is the central configuration type
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server Server `yaml:"server" json:"server" koanf:"server"`
|
Server Server `yaml:"server" json:"server" koanf:"server"`
|
||||||
Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn" koanf:"webauthn"`
|
Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn" koanf:"webauthn"`
|
||||||
Passcode Passcode `yaml:"passcode" json:"passcode" koanf:"passcode"`
|
Passcode Passcode `yaml:"passcode" json:"passcode" koanf:"passcode"`
|
||||||
Password Password `yaml:"password" json:"password" koanf:"password"`
|
Password Password `yaml:"password" json:"password" koanf:"password"`
|
||||||
Database Database `yaml:"database" json:"database" koanf:"database"`
|
Database Database `yaml:"database" json:"database" koanf:"database"`
|
||||||
Secrets Secrets `yaml:"secrets" json:"secrets" koanf:"secrets"`
|
Secrets Secrets `yaml:"secrets" json:"secrets" koanf:"secrets"`
|
||||||
Service Service `yaml:"service" json:"service" koanf:"service"`
|
Service Service `yaml:"service" json:"service" koanf:"service"`
|
||||||
Session Session `yaml:"session" json:"session" koanf:"session"`
|
Session Session `yaml:"session" json:"session" koanf:"session"`
|
||||||
AuditLog AuditLog `yaml:"audit_log" json:"audit_log" koanf:"audit_log"`
|
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) {
|
func Load(cfgFile *string) (*Config, error) {
|
||||||
k := koanf.New(".")
|
k := koanf.New(".")
|
||||||
var err error
|
var err error
|
||||||
@ -58,6 +72,14 @@ func DefaultConfig() *Config {
|
|||||||
Server: Server{
|
Server: Server{
|
||||||
Public: ServerSettings{
|
Public: ServerSettings{
|
||||||
Address: ":8000",
|
Address: ":8000",
|
||||||
|
Cors: Cors{
|
||||||
|
ExposeHeaders: []string{
|
||||||
|
HeaderRateLimitLimit,
|
||||||
|
HeaderRateLimitRemaining,
|
||||||
|
HeaderRateLimitReset,
|
||||||
|
HeaderRetryAfter,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Admin: ServerSettings{
|
Admin: ServerSettings{
|
||||||
Address: ":8001",
|
Address: ":8001",
|
||||||
@ -67,7 +89,7 @@ func DefaultConfig() *Config {
|
|||||||
RelyingParty: RelyingParty{
|
RelyingParty: RelyingParty{
|
||||||
Id: "localhost",
|
Id: "localhost",
|
||||||
DisplayName: "Hanko Authentication Service",
|
DisplayName: "Hanko Authentication Service",
|
||||||
Origin: "http://localhost",
|
Origins: []string{"http://localhost"},
|
||||||
},
|
},
|
||||||
Timeout: 60000,
|
Timeout: 60000,
|
||||||
},
|
},
|
||||||
@ -76,6 +98,10 @@ func DefaultConfig() *Config {
|
|||||||
Port: "465",
|
Port: "465",
|
||||||
},
|
},
|
||||||
TTL: 300,
|
TTL: 300,
|
||||||
|
Email: Email{
|
||||||
|
FromAddress: "passcode@hanko.io",
|
||||||
|
FromName: "Hanko",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Password: Password{
|
Password: Password{
|
||||||
MinPasswordLength: 8,
|
MinPasswordLength: 8,
|
||||||
@ -97,6 +123,9 @@ func DefaultConfig() *Config {
|
|||||||
OutputStream: OutputStreamStdOut,
|
OutputStream: OutputStreamStdOut,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
RateLimiter: RateLimiter{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,6 +158,10 @@ func (c *Config) Validate() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to validate session settings: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,3 +387,42 @@ var (
|
|||||||
OutputStreamStdOut OutputStream = "stdout"
|
OutputStreamStdOut OutputStream = "stdout"
|
||||||
OutputStreamStdErr OutputStream = "stderr"
|
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/pop/v6 v6.1.0
|
||||||
github.com/gobuffalo/validate/v3 v3.3.3
|
github.com/gobuffalo/validate/v3 v3.3.3
|
||||||
github.com/gofrs/uuid v4.3.1+incompatible
|
github.com/gofrs/uuid v4.3.1+incompatible
|
||||||
|
github.com/gomodule/redigo v1.8.2
|
||||||
github.com/knadh/koanf v1.4.4
|
github.com/knadh/koanf v1.4.4
|
||||||
github.com/labstack/echo/v4 v4.9.1
|
github.com/labstack/echo/v4 v4.9.1
|
||||||
github.com/lestrrat-go/jwx/v2 v2.0.8
|
github.com/lestrrat-go/jwx/v2 v2.0.8
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.2.1
|
github.com/nicksnyder/go-i18n/v2 v2.2.1
|
||||||
github.com/rs/zerolog v1.28.0
|
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/spf13/cobra v1.6.1
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.1
|
||||||
golang.org/x/crypto v0.5.0
|
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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
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/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 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/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
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/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 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
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 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 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
|
||||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
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/gobuffalo/pop/v6"
|
||||||
"github.com/gofrs/uuid"
|
"github.com/gofrs/uuid"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/sethvargo/go-limiter"
|
||||||
"github.com/teamhanko/hanko/backend/audit_log"
|
"github.com/teamhanko/hanko/backend/audit_log"
|
||||||
"github.com/teamhanko/hanko/backend/config"
|
"github.com/teamhanko/hanko/backend/config"
|
||||||
"github.com/teamhanko/hanko/backend/crypto"
|
"github.com/teamhanko/hanko/backend/crypto"
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/teamhanko/hanko/backend/mail"
|
"github.com/teamhanko/hanko/backend/mail"
|
||||||
"github.com/teamhanko/hanko/backend/persistence"
|
"github.com/teamhanko/hanko/backend/persistence"
|
||||||
"github.com/teamhanko/hanko/backend/persistence/models"
|
"github.com/teamhanko/hanko/backend/persistence/models"
|
||||||
|
"github.com/teamhanko/hanko/backend/rate_limiter"
|
||||||
"github.com/teamhanko/hanko/backend/session"
|
"github.com/teamhanko/hanko/backend/session"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gopkg.in/gomail.v2"
|
"gopkg.in/gomail.v2"
|
||||||
@ -31,6 +33,7 @@ type PasscodeHandler struct {
|
|||||||
sessionManager session.Manager
|
sessionManager session.Manager
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
auditLogger auditlog.Logger
|
auditLogger auditlog.Logger
|
||||||
|
rateLimiter limiter.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxPasscodeTries = 3
|
var maxPasscodeTries = 3
|
||||||
@ -40,6 +43,10 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create new renderer: %w", err)
|
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{
|
return &PasscodeHandler{
|
||||||
mailer: mailer,
|
mailer: mailer,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
@ -51,6 +58,7 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses
|
|||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
auditLogger: auditLogger,
|
auditLogger: auditLogger,
|
||||||
|
rateLimiter: rateLimiter,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +89,13 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
|
|||||||
return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found"))
|
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()
|
passcode, err := h.passcodeGenerator.Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate passcode: %w", err)
|
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) {
|
if (response.status === 429) {
|
||||||
const retryAfter = parseInt(
|
const retryAfter = parseInt(
|
||||||
response.headers.get("X-Retry-After") || "0",
|
response.headers.get("Retry-After") || "0",
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,7 @@ class PasswordClient extends Client {
|
|||||||
throw new InvalidPasswordError();
|
throw new InvalidPasswordError();
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
const retryAfter = parseInt(
|
const retryAfter = parseInt(
|
||||||
response.headers.get("X-Retry-After") || "0",
|
response.headers.get("Retry-After") || "0",
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ describe("PasscodeClient.initialize()", () => {
|
|||||||
passcodeRetryAfter
|
passcodeRetryAfter
|
||||||
);
|
);
|
||||||
expect(passcodeClient.state.write).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("should throw error when API response is not ok", async () => {
|
||||||
|
|||||||
@ -63,7 +63,7 @@ describe("PasswordClient.login()", () => {
|
|||||||
passwordRetryAfter
|
passwordRetryAfter
|
||||||
);
|
);
|
||||||
expect(passwordClient.state.write).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("should throw error when API response is not ok", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user