From c83b7e748f64d6a884ef03fe0f5eb57b2c4df077 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:30:15 +0700 Subject: [PATCH 1/8] feat(ratelimit): add authentication rate limiting backends --- go.mod | 3 + go.sum | 8 + .../adapter/outbound/postgres/db/schema.sql | 9 + internal/adapter/outbound/postgres/models.go | 7 + .../outbound/ratelimit/contract_test.go | 34 ++++ internal/adapter/outbound/ratelimit/memory.go | 98 +++++++++++ .../adapter/outbound/ratelimit/memory_test.go | 68 ++++++++ .../adapter/outbound/ratelimit/postgres.go | 142 ++++++++++++++++ .../ratelimit/postgres_integration_test.go | 72 ++++++++ internal/adapter/outbound/ratelimit/redis.go | 96 +++++++++++ .../ratelimit/redis_integration_test.go | 74 +++++++++ internal/bootstrap/bootstrap.go | 17 ++ internal/bootstrap/ratelimit.go | 23 +++ internal/core/port/rate_limiter.go | 27 +++ internal/platform/config.go | 2 + internal/platform/rate_limit_config.go | 155 ++++++++++++++++++ internal/platform/rate_limit_config_test.go | 50 ++++++ internal/platform/rate_limiter.go | 78 +++++++++ internal/platform/rate_limiter_test.go | 42 +++++ internal/platform/redis.go | 11 ++ migrations/000003_rate_limits.down.sql | 1 + migrations/000003_rate_limits.up.sql | 9 + 22 files changed, 1026 insertions(+) create mode 100644 internal/adapter/outbound/ratelimit/contract_test.go create mode 100644 internal/adapter/outbound/ratelimit/memory.go create mode 100644 internal/adapter/outbound/ratelimit/memory_test.go create mode 100644 internal/adapter/outbound/ratelimit/postgres.go create mode 100644 internal/adapter/outbound/ratelimit/postgres_integration_test.go create mode 100644 internal/adapter/outbound/ratelimit/redis.go create mode 100644 internal/adapter/outbound/ratelimit/redis_integration_test.go create mode 100644 internal/bootstrap/ratelimit.go create mode 100644 internal/core/port/rate_limiter.go create mode 100644 internal/platform/rate_limit_config.go create mode 100644 internal/platform/rate_limit_config_test.go create mode 100644 internal/platform/rate_limiter.go create mode 100644 internal/platform/rate_limiter_test.go create mode 100644 internal/platform/redis.go create mode 100644 migrations/000003_rate_limits.down.sql create mode 100644 migrations/000003_rate_limits.up.sql diff --git a/go.mod b/go.mod index d8ea4a4..ec36739 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,15 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/markbates/goth v1.81.0 + github.com/redis/go-redis/v9 v9.7.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) +require github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index f582888..b488578 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -22,6 +26,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= @@ -141,6 +147,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= diff --git a/internal/adapter/outbound/postgres/db/schema.sql b/internal/adapter/outbound/postgres/db/schema.sql index 5091cfd..8090fb4 100644 --- a/internal/adapter/outbound/postgres/db/schema.sql +++ b/internal/adapter/outbound/postgres/db/schema.sql @@ -44,3 +44,12 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id ON refresh_tokens(family_id); + +CREATE TABLE IF NOT EXISTS rate_limit_buckets ( + key_hash TEXT PRIMARY KEY, + count BIGINT NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires_at ON rate_limit_buckets(expires_at); diff --git a/internal/adapter/outbound/postgres/models.go b/internal/adapter/outbound/postgres/models.go index 100db79..75f7679 100644 --- a/internal/adapter/outbound/postgres/models.go +++ b/internal/adapter/outbound/postgres/models.go @@ -16,6 +16,13 @@ type ForgotPasswordEmail struct { CreatedAt pgtype.Timestamp } +type RateLimitBucket struct { + KeyHash string + Count int64 + WindowStart pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz +} + type RefreshToken struct { ID pgtype.UUID UserID int32 diff --git a/internal/adapter/outbound/ratelimit/contract_test.go b/internal/adapter/outbound/ratelimit/contract_test.go new file mode 100644 index 0000000..cf7e316 --- /dev/null +++ b/internal/adapter/outbound/ratelimit/contract_test.go @@ -0,0 +1,34 @@ +package ratelimit + +import ( + "context" + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +func runStoreContract(t *testing.T, store port.RateLimitStore) { + t.Helper() + ctx := context.Background() + policy := port.RateLimitPolicy{Name: "contract", Limit: 2, Window: time.Minute} + key := "contract-" + time.Now().Format("150405.000000000") + + for i := 0; i < 2; i++ { + decision, err := store.Allow(ctx, key, policy) + if err != nil || !decision.Allowed { + t.Fatalf("request %d = %#v, %v; want allowed", i, decision, err) + } + } + decision, err := store.Allow(ctx, key, policy) + if err != nil || decision.Allowed || decision.RetryAfter <= 0 { + t.Fatalf("limited request = %#v, %v; want retry duration", decision, err) + } + if err := store.Reset(ctx, key); err != nil { + t.Fatal(err) + } + decision, err = store.Allow(ctx, key, policy) + if err != nil || !decision.Allowed { + t.Fatalf("request after reset = %#v, %v; want allowed", decision, err) + } +} diff --git a/internal/adapter/outbound/ratelimit/memory.go b/internal/adapter/outbound/ratelimit/memory.go new file mode 100644 index 0000000..d843efb --- /dev/null +++ b/internal/adapter/outbound/ratelimit/memory.go @@ -0,0 +1,98 @@ +package ratelimit + +import ( + "context" + "sync" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +type MemoryStore struct { + mu sync.Mutex + entries map[string]memoryEntry + maxKeys int + now func() time.Time +} + +type memoryEntry struct { + count int + started time.Time + window time.Duration +} + +func NewMemoryStore(maxKeys int) *MemoryStore { + if maxKeys < 1 { + maxKeys = 10_000 + } + return &MemoryStore{ + entries: make(map[string]memoryEntry), + maxKeys: maxKeys, + now: time.Now, + } +} + +func (s *MemoryStore) Allow(ctx context.Context, key string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { + if err := ctx.Err(); err != nil { + return port.RateLimitDecision{}, err + } + if policy.Limit < 1 || policy.Window <= 0 { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + + now := s.now() + s.mu.Lock() + defer s.mu.Unlock() + + for storedKey, entry := range s.entries { + if !now.Before(entry.started.Add(entry.window)) { + delete(s.entries, storedKey) + } + } + entry, exists := s.entries[key] + if !exists || !now.Before(entry.started.Add(entry.window)) { + if !exists && len(s.entries) >= s.maxKeys { + s.evictOldest() + } + s.entries[key] = memoryEntry{count: 1, started: now, window: policy.Window} + return port.RateLimitDecision{Allowed: true}, nil + } + + resetAt := entry.started.Add(entry.window) + if entry.count >= policy.Limit { + return port.RateLimitDecision{ + RetryAfter: resetAt.Sub(now), + }, nil + } + entry.count++ + s.entries[key] = entry + return port.RateLimitDecision{Allowed: true}, nil +} + +func (s *MemoryStore) Reset(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + delete(s.entries, key) + s.mu.Unlock() + return nil +} + +func (s *MemoryStore) Backend() string { return "memory" } + +func (s *MemoryStore) Close() error { return nil } + +func (s *MemoryStore) evictOldest() { + var oldestKey string + var oldest time.Time + for key, entry := range s.entries { + if oldestKey == "" || entry.started.Before(oldest) { + oldestKey = key + oldest = entry.started + } + } + if oldestKey != "" { + delete(s.entries, oldestKey) + } +} diff --git a/internal/adapter/outbound/ratelimit/memory_test.go b/internal/adapter/outbound/ratelimit/memory_test.go new file mode 100644 index 0000000..95e6e83 --- /dev/null +++ b/internal/adapter/outbound/ratelimit/memory_test.go @@ -0,0 +1,68 @@ +package ratelimit + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +func TestMemoryStoreContract(t *testing.T) { + runStoreContract(t, NewMemoryStore(10)) +} + +func TestMemoryStoreEnforcesAndResetsWindow(t *testing.T) { + now := time.Unix(0, 0) + store := NewMemoryStore(10) + store.now = func() time.Time { return now } + policy := port.RateLimitPolicy{Name: "test", Limit: 2, Window: time.Minute} + + for i := 0; i < 2; i++ { + decision, err := store.Allow(context.Background(), "key", policy) + if err != nil || !decision.Allowed { + t.Fatalf("request %d = %#v, %v; want allowed", i, decision, err) + } + } + decision, err := store.Allow(context.Background(), "key", policy) + if err != nil || decision.Allowed || decision.RetryAfter != time.Minute { + t.Fatalf("limited request = %#v, %v; want one minute retry", decision, err) + } + + now = now.Add(time.Minute) + decision, err = store.Allow(context.Background(), "key", policy) + if err != nil || !decision.Allowed { + t.Fatalf("request after expiry = %#v, %v; want allowed", decision, err) + } + if err := store.Reset(context.Background(), "key"); err != nil { + t.Fatal(err) + } +} + +func TestMemoryStoreBoundsKeys(t *testing.T) { + store := NewMemoryStore(2) + policy := port.RateLimitPolicy{Name: "test", Limit: 1, Window: time.Minute} + for _, key := range []string{"one", "two", "three"} { + if _, err := store.Allow(context.Background(), key, policy); err != nil { + t.Fatal(err) + } + } + if len(store.entries) != 2 { + t.Fatalf("entries = %d, want 2", len(store.entries)) + } +} + +func TestMemoryStoreConcurrentAccess(t *testing.T) { + store := NewMemoryStore(100) + policy := port.RateLimitPolicy{Name: "test", Limit: 100, Window: time.Minute} + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = store.Allow(context.Background(), "key", policy) + }() + } + wg.Wait() +} diff --git a/internal/adapter/outbound/ratelimit/postgres.go b/internal/adapter/outbound/ratelimit/postgres.go new file mode 100644 index 0000000..ac1b351 --- /dev/null +++ b/internal/adapter/outbound/ratelimit/postgres.go @@ -0,0 +1,142 @@ +package ratelimit + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct { + pool *pgxpool.Pool + cancel context.CancelFunc + wg sync.WaitGroup +} + +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + ctx, cancel := context.WithCancel(context.Background()) + store := &PostgresStore{pool: pool, cancel: cancel} + store.wg.Add(1) + go store.cleanupLoop(ctx) + return store +} + +func (s *PostgresStore) Allow(ctx context.Context, key string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { + if err := ctx.Err(); err != nil { + return port.RateLimitDecision{}, err + } + if s.pool == nil || policy.Limit < 1 || policy.Window <= 0 { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + + now := time.Now().UTC() + tx, err := s.pool.Begin(ctx) + if err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, key); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + if _, err := tx.Exec(ctx, `DELETE FROM rate_limit_buckets WHERE key_hash = $1 AND expires_at <= $2`, key, now); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + + var count int + var started time.Time + err = tx.QueryRow(ctx, ` + SELECT count, window_start + FROM rate_limit_buckets + WHERE key_hash = $1 + `, key).Scan(&count, &started) + if errors.Is(err, pgx.ErrNoRows) { + if _, err := tx.Exec(ctx, ` + INSERT INTO rate_limit_buckets (key_hash, count, window_start, expires_at) + VALUES ($1, 1, $2, $3) + `, key, now, now.Add(policy.Window)); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + if err := tx.Commit(ctx); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + return port.RateLimitDecision{Allowed: true}, nil + } + if err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + + resetAt := started.Add(policy.Window) + if !now.Before(resetAt) { + if _, err := tx.Exec(ctx, ` + UPDATE rate_limit_buckets + SET count = 1, window_start = $2, expires_at = $3 + WHERE key_hash = $1 + `, key, now, now.Add(policy.Window)); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + if err := tx.Commit(ctx); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + return port.RateLimitDecision{Allowed: true}, nil + } + if count >= policy.Limit { + if err := tx.Commit(ctx); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + return port.RateLimitDecision{RetryAfter: resetAt.Sub(now)}, nil + } + + if _, err := tx.Exec(ctx, `UPDATE rate_limit_buckets SET count = count + 1 WHERE key_hash = $1`, key); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + if err := tx.Commit(ctx); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + return port.RateLimitDecision{Allowed: true}, nil +} + +func (s *PostgresStore) Reset(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + if s.pool == nil { + return port.ErrRateLimitBackendUnavailable + } + if _, err := s.pool.Exec(ctx, `DELETE FROM rate_limit_buckets WHERE key_hash = $1`, key); err != nil { + return port.ErrRateLimitBackendUnavailable + } + return nil +} + +func (s *PostgresStore) Backend() string { return "postgres" } + +func (s *PostgresStore) Close() error { + if s.cancel != nil { + s.cancel() + s.wg.Wait() + } + return nil +} + +func (s *PostgresStore) cleanupLoop(ctx context.Context) { + defer s.wg.Done() + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if s.pool != nil { + _, _ = s.pool.Exec(ctx, `DELETE FROM rate_limit_buckets WHERE expires_at <= now()`) + } + } + } +} + +var _ port.RateLimitStore = (*PostgresStore)(nil) diff --git a/internal/adapter/outbound/ratelimit/postgres_integration_test.go b/internal/adapter/outbound/ratelimit/postgres_integration_test.go new file mode 100644 index 0000000..00bc8ca --- /dev/null +++ b/internal/adapter/outbound/ratelimit/postgres_integration_test.go @@ -0,0 +1,72 @@ +//go:build integration + +package ratelimit + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestPostgresStoreContract(t *testing.T) { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + t.Skip("DATABASE_URL is not set") + } + pool, err := pgxpool.New(context.Background(), databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + + store := NewPostgresStore(pool) + defer store.Close() + runStoreContract(t, store) +} + +func TestPostgresStoreConcurrentLimit(t *testing.T) { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + t.Skip("DATABASE_URL is not set") + } + pool, err := pgxpool.New(context.Background(), databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + + store := NewPostgresStore(pool) + defer store.Close() + policy := port.RateLimitPolicy{Name: "concurrent", Limit: 5, Window: time.Minute} + key := "concurrent-" + time.Now().Format("150405.000000000") + var wg sync.WaitGroup + results := make(chan bool, 20) + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + decision, err := store.Allow(context.Background(), key, policy) + results <- err == nil && decision.Allowed + }() + } + wg.Wait() + close(results) + + allowed := 0 + for result := range results { + if result { + allowed++ + } + } + if allowed != policy.Limit { + t.Fatalf("allowed = %d, want %d", allowed, policy.Limit) + } + if err := store.Reset(context.Background(), key); err != nil { + t.Fatal(err) + } +} diff --git a/internal/adapter/outbound/ratelimit/redis.go b/internal/adapter/outbound/ratelimit/redis.go new file mode 100644 index 0000000..50d56c3 --- /dev/null +++ b/internal/adapter/outbound/ratelimit/redis.go @@ -0,0 +1,96 @@ +package ratelimit + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/redis/go-redis/v9" +) + +var consumeScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then + redis.call("SET", KEYS[1], 1, "PX", ARGV[2]) + return {1, ARGV[2]} +end +if tonumber(current) >= tonumber(ARGV[1]) then + return {0, redis.call("PTTL", KEYS[1])} +end +redis.call("INCR", KEYS[1]) +return {1, redis.call("PTTL", KEYS[1])} +`) + +type RedisStore struct { + client *redis.Client +} + +func NewRedisStore(client *redis.Client) *RedisStore { + return &RedisStore{client: client} +} + +func (s *RedisStore) Allow(ctx context.Context, key string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { + if err := ctx.Err(); err != nil { + return port.RateLimitDecision{}, err + } + if s.client == nil || policy.Limit < 1 || policy.Window <= 0 { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + window := policy.Window.Milliseconds() + if window < 1 { + window = 1 + } + result, err := consumeScript.Run(ctx, s.client, []string{key}, policy.Limit, window).Result() + if err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + values, ok := result.([]interface{}) + if !ok || len(values) != 2 { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + allowed, err := strconv.ParseInt(toString(values[0]), 10, 64) + if err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + ttl, err := strconv.ParseInt(toString(values[1]), 10, 64) + if err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + decision := port.RateLimitDecision{Allowed: allowed == 1} + if !decision.Allowed { + decision.RetryAfter = time.Duration(ttl) * time.Millisecond + } + return decision, nil +} + +func (s *RedisStore) Reset(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + if s.client == nil { + return port.ErrRateLimitBackendUnavailable + } + if err := s.client.Del(ctx, key).Err(); err != nil { + return port.ErrRateLimitBackendUnavailable + } + return nil +} + +func (s *RedisStore) Backend() string { return "redis" } + +func (s *RedisStore) Close() error { return nil } + +func toString(value interface{}) string { + switch v := value.(type) { + case string: + return v + case []byte: + return string(v) + default: + return fmt.Sprint(v) + } +} + +var _ port.RateLimitStore = (*RedisStore)(nil) diff --git a/internal/adapter/outbound/ratelimit/redis_integration_test.go b/internal/adapter/outbound/ratelimit/redis_integration_test.go new file mode 100644 index 0000000..f041eb3 --- /dev/null +++ b/internal/adapter/outbound/ratelimit/redis_integration_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package ratelimit + +import ( + "context" + "os" + "strconv" + "sync" + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/platform" +) + +func newIntegrationRedisStore(t *testing.T) *RedisStore { + t.Helper() + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr == "" { + t.Skip("REDIS_ADDR is not set") + } + database := 0 + if raw := os.Getenv("REDIS_DB"); raw != "" { + var err error + database, err = strconv.Atoi(raw) + if err != nil { + t.Fatal(err) + } + } + client := platform.NewRedisClient(redisAddr, os.Getenv("REDIS_PASSWORD"), database) + if err := client.Ping(context.Background()).Err(); err != nil { + _ = client.Close() + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return NewRedisStore(client) +} + +func TestRedisStoreContract(t *testing.T) { + store := newIntegrationRedisStore(t) + runStoreContract(t, store) +} + +func TestRedisStoreConcurrentLimit(t *testing.T) { + store := newIntegrationRedisStore(t) + policy := port.RateLimitPolicy{Name: "concurrent", Limit: 5, Window: time.Minute} + key := "concurrent-" + time.Now().Format("150405.000000000") + var wg sync.WaitGroup + results := make(chan bool, 20) + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + decision, err := store.Allow(context.Background(), key, policy) + results <- err == nil && decision.Allowed + }() + } + wg.Wait() + close(results) + + allowed := 0 + for result := range results { + if result { + allowed++ + } + } + if allowed != policy.Limit { + t.Fatalf("allowed = %d, want %d", allowed, policy.Limit) + } + if err := store.Reset(context.Background(), key); err != nil { + t.Fatal(err) + } +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index a65e973..f1faf19 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,6 +1,8 @@ package bootstrap import ( + "log" + "github.com/gin-gonic/gin" inhttp "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http" @@ -15,6 +17,9 @@ import ( ) func Run(cfg platform.Config) { + if err := cfg.RateLimit.Validate(cfg.Environment); err != nil { + log.Fatal(err) + } pool := platform.NewPool() defer pool.Close() @@ -33,9 +38,20 @@ func Run(cfg platform.Config) { svc := service.New(repo, tokens, email, hasher, cfg.BaseURL, oauth) r := gin.New() + if err := r.SetTrustedProxies(cfg.RateLimit.TrustedProxies); err != nil { + log.Fatal("invalid trusted proxies configuration") + } logger := platform.NewLogger(cfg.LogLevel) metrics := platform.NewMetrics(pool) audit := platform.NewAuditLogger(logger, metrics) + rateLimitStore, redisClient, err := newRateLimitStore(cfg.RateLimit, pool) + if err != nil { + log.Fatal("rate-limit backend is unavailable") + } + defer rateLimitStore.Close() + if redisClient != nil { + defer redisClient.Close() + } r.Use(inhttpmw.RequestID()) if cfg.EnableMetrics { @@ -45,6 +61,7 @@ func Run(cfg platform.Config) { handler := inhttp.NewHandler(svc, tokens) handler.Audit = audit + handler.RateLimiter = platform.NewRateLimiter(rateLimitStore, cfg.RateLimit.Key, cfg.RateLimit.Policies) inhttp.RegisterRoutes(r, handler, logger) inhttp.RegisterSwaggerRoutes(r, cfg.EnableSwagger, cfg.Environment) inhttp.RegisterHealthRoutes(r, pool) diff --git a/internal/bootstrap/ratelimit.go b/internal/bootstrap/ratelimit.go new file mode 100644 index 0000000..6b014d9 --- /dev/null +++ b/internal/bootstrap/ratelimit.go @@ -0,0 +1,23 @@ +package bootstrap + +import ( + "github.com/Jonathan0823/auth-go/internal/adapter/outbound/ratelimit" + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +func newRateLimitStore(config platform.RateLimitConfig, pool *pgxpool.Pool) (port.RateLimitStore, *redis.Client, error) { + switch config.Backend { + case "memory": + return ratelimit.NewMemoryStore(config.MaxMemoryKeys), nil, nil + case "postgres": + return ratelimit.NewPostgresStore(pool), nil, nil + case "redis": + client := platform.NewRedisClient(config.RedisAddr, config.RedisPassword, config.RedisDB) + return ratelimit.NewRedisStore(client), client, nil + default: + return nil, nil, port.ErrRateLimitBackendUnavailable + } +} diff --git a/internal/core/port/rate_limiter.go b/internal/core/port/rate_limiter.go new file mode 100644 index 0000000..4f46440 --- /dev/null +++ b/internal/core/port/rate_limiter.go @@ -0,0 +1,27 @@ +package port + +import ( + "context" + "errors" + "time" +) + +var ErrRateLimitBackendUnavailable = errors.New("rate limit backend unavailable") + +type RateLimitPolicy struct { + Name string + Limit int + Window time.Duration +} + +type RateLimitDecision struct { + Allowed bool + RetryAfter time.Duration +} + +type RateLimitStore interface { + Allow(context.Context, string, RateLimitPolicy) (RateLimitDecision, error) + Reset(context.Context, string) error + Backend() string + Close() error +} diff --git a/internal/platform/config.go b/internal/platform/config.go index 6858cdd..e3241e9 100644 --- a/internal/platform/config.go +++ b/internal/platform/config.go @@ -15,6 +15,7 @@ type Config struct { GoogleClientSecret string EnableSwagger bool EnableMetrics bool + RateLimit RateLimitConfig } func LoadConfig() Config { @@ -31,5 +32,6 @@ func LoadConfig() Config { GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), EnableSwagger: os.Getenv("ENABLE_SWAGGER") == "true", EnableMetrics: os.Getenv("ENABLE_METRICS") == "true", + RateLimit: LoadRateLimitConfig(), } } diff --git a/internal/platform/rate_limit_config.go b/internal/platform/rate_limit_config.go new file mode 100644 index 0000000..def4a35 --- /dev/null +++ b/internal/platform/rate_limit_config.go @@ -0,0 +1,155 @@ +package platform + +import ( + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +type RateLimitConfig struct { + Backend string + RedisAddr string + RedisPassword string + RedisDB int + Key string + TrustedProxies []string + AllowMemoryInProduction bool + MaxMemoryKeys int + Policies map[string]port.RateLimitPolicy + invalidPolicyConfiguration []string +} + +func LoadRateLimitConfig() RateLimitConfig { + backend := os.Getenv("RATE_LIMIT_BACKEND") + if backend == "" { + backend = "memory" + } + maxKeys := 10_000 + invalidMaxKeys := false + if raw := os.Getenv("RATE_LIMIT_MAX_MEMORY_KEYS"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + invalidMaxKeys = true + } else { + maxKeys = parsed + } + } + config := RateLimitConfig{ + Backend: backend, + RedisAddr: os.Getenv("REDIS_ADDR"), + RedisPassword: os.Getenv("REDIS_PASSWORD"), + Key: os.Getenv("RATE_LIMIT_KEY"), + TrustedProxies: splitList(os.Getenv("TRUSTED_PROXIES")), + AllowMemoryInProduction: os.Getenv("RATE_LIMIT_ALLOW_MEMORY_PRODUCTION") == "true", + MaxMemoryKeys: maxKeys, + Policies: make(map[string]port.RateLimitPolicy), + } + if invalidMaxKeys { + config.invalidPolicyConfiguration = append(config.invalidPolicyConfiguration, "RATE_LIMIT_MAX_MEMORY_KEYS") + } + config.RedisDB = loadRedisDB(&config) + for name, fallback := range defaultRateLimitPolicies() { + config.Policies[name] = loadRateLimitPolicy(&config, name, fallback) + } + return config +} + +func (c RateLimitConfig) Validate(environment string) error { + switch c.Backend { + case "memory": + if environment == "production" && !c.AllowMemoryInProduction { + return fmt.Errorf("memory rate-limit backend is disabled in production") + } + case "redis": + if c.RedisAddr == "" { + return fmt.Errorf("REDIS_ADDR is required for the redis rate-limit backend") + } + if c.RedisDB < 0 { + return fmt.Errorf("REDIS_DB must not be negative") + } + case "postgres": + default: + return fmt.Errorf("unsupported rate-limit backend %q", c.Backend) + } + if c.Key == "" { + return fmt.Errorf("RATE_LIMIT_KEY is required") + } + if c.MaxMemoryKeys < 1 { + return fmt.Errorf("RATE_LIMIT_MAX_MEMORY_KEYS must be positive") + } + if len(c.invalidPolicyConfiguration) > 0 { + return fmt.Errorf("invalid rate-limit policies: %s", strings.Join(c.invalidPolicyConfiguration, ", ")) + } + for _, proxy := range c.TrustedProxies { + if net.ParseIP(proxy) == nil { + if _, _, err := net.ParseCIDR(proxy); err != nil { + return fmt.Errorf("invalid trusted proxy address %q", proxy) + } + } + } + return nil +} + +func defaultRateLimitPolicies() map[string]port.RateLimitPolicy { + return map[string]port.RateLimitPolicy{ + "login_ip": {Name: "login_ip", Limit: 30, Window: time.Minute}, + "login_account": {Name: "login_account", Limit: 5, Window: 15 * time.Minute}, + "register_ip": {Name: "register_ip", Limit: 5, Window: time.Hour}, + "register_email": {Name: "register_email", Limit: 5, Window: time.Hour}, + "recovery_ip": {Name: "recovery_ip", Limit: 10, Window: time.Hour}, + "recovery_email": {Name: "recovery_email", Limit: 3, Window: time.Hour}, + "recovery_token": {Name: "recovery_token", Limit: 3, Window: time.Hour}, + "refresh_ip": {Name: "refresh_ip", Limit: 30, Window: time.Minute}, + "verify_ip": {Name: "verify_ip", Limit: 10, Window: time.Hour}, + "verify_email": {Name: "verify_email", Limit: 3, Window: time.Hour}, + "oauth_ip": {Name: "oauth_ip", Limit: 10, Window: time.Minute}, + } +} + +func loadRateLimitPolicy(config *RateLimitConfig, name string, fallback port.RateLimitPolicy) port.RateLimitPolicy { + envName := "RATE_LIMIT_" + strings.ToUpper(name) + raw := os.Getenv(envName) + if raw == "" { + return fallback + } + parts := strings.SplitN(raw, "/", 2) + if len(parts) != 2 { + config.invalidPolicyConfiguration = append(config.invalidPolicyConfiguration, envName) + return port.RateLimitPolicy{Name: name} + } + limit, limitErr := strconv.Atoi(parts[0]) + window, windowErr := time.ParseDuration(parts[1]) + if limitErr != nil || windowErr != nil || limit < 1 || window <= 0 { + config.invalidPolicyConfiguration = append(config.invalidPolicyConfiguration, envName) + return port.RateLimitPolicy{Name: name} + } + return port.RateLimitPolicy{Name: name, Limit: limit, Window: window} +} + +func loadRedisDB(config *RateLimitConfig) int { + raw := os.Getenv("REDIS_DB") + if raw == "" { + return 0 + } + db, err := strconv.Atoi(raw) + if err != nil || db < 0 { + config.invalidPolicyConfiguration = append(config.invalidPolicyConfiguration, "REDIS_DB") + return -1 + } + return db +} + +func splitList(value string) []string { + var result []string + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" { + result = append(result, item) + } + } + return result +} diff --git a/internal/platform/rate_limit_config_test.go b/internal/platform/rate_limit_config_test.go new file mode 100644 index 0000000..a0a6d6e --- /dev/null +++ b/internal/platform/rate_limit_config_test.go @@ -0,0 +1,50 @@ +package platform + +import ( + "testing" +) + +func TestLoadRateLimitConfigDefaults(t *testing.T) { + for _, name := range []string{ + "RATE_LIMIT_BACKEND", "RATE_LIMIT_KEY", "TRUSTED_PROXIES", + "RATE_LIMIT_ALLOW_MEMORY_PRODUCTION", "RATE_LIMIT_MAX_MEMORY_KEYS", + } { + t.Setenv(name, "") + } + t.Setenv("REDIS_ADDR", "") + t.Setenv("REDIS_PASSWORD", "") + t.Setenv("REDIS_DB", "") + t.Setenv("RATE_LIMIT_KEY", "test-key") + config := LoadRateLimitConfig() + if config.Backend != "memory" || config.MaxMemoryKeys != 10_000 { + t.Fatalf("defaults = %#v", config) + } + if err := config.Validate("development"); err != nil { + t.Fatalf("development validation = %v", err) + } + if err := config.Validate("production"); err == nil { + t.Fatal("memory backend should be rejected in production") + } +} + +func TestRateLimitConfigValidatesTrustedProxiesAndPolicies(t *testing.T) { + t.Setenv("RATE_LIMIT_BACKEND", "redis") + t.Setenv("REDIS_ADDR", "localhost:6379") + t.Setenv("REDIS_PASSWORD", "") + t.Setenv("REDIS_DB", "0") + t.Setenv("RATE_LIMIT_KEY", "test-key") + t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8, 192.168.1.1") + t.Setenv("RATE_LIMIT_LOGIN_IP", "2/10s") + config := LoadRateLimitConfig() + if err := config.Validate("production"); err != nil { + t.Fatal(err) + } + if got := config.Policies["login_ip"]; got.Limit != 2 || got.Window.String() != "10s" { + t.Fatalf("login policy = %#v", got) + } + + t.Setenv("TRUSTED_PROXIES", "not-a-cidr") + if err := LoadRateLimitConfig().Validate("production"); err == nil { + t.Fatal("invalid trusted proxy should fail validation") + } +} diff --git a/internal/platform/rate_limiter.go b/internal/platform/rate_limiter.go new file mode 100644 index 0000000..8b4d7ce --- /dev/null +++ b/internal/platform/rate_limiter.go @@ -0,0 +1,78 @@ +package platform + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +type RateLimitCheck struct { + Policy string + Dimension string + Value string +} + +type RateLimiter struct { + store port.RateLimitStore + key []byte + policies map[string]port.RateLimitPolicy +} + +func NewRateLimiter(store port.RateLimitStore, key string, policies map[string]port.RateLimitPolicy) *RateLimiter { + return &RateLimiter{store: store, key: []byte(key), policies: policies} +} + +func (l *RateLimiter) Allow(ctx context.Context, checks ...RateLimitCheck) (port.RateLimitDecision, error) { + if l == nil || l.store == nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + var retryAfter time.Duration + for _, check := range checks { + policy, ok := l.policies[check.Policy] + if !ok || policy.Limit < 1 || policy.Window <= 0 { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + } + decision, err := l.store.Allow(ctx, l.keyFor(check), policy) + if err != nil { + return port.RateLimitDecision{}, err + } + if !decision.Allowed && decision.RetryAfter > retryAfter { + retryAfter = decision.RetryAfter + } + if !decision.Allowed { + return port.RateLimitDecision{RetryAfter: retryAfter}, nil + } + } + return port.RateLimitDecision{Allowed: true}, nil +} + +func (l *RateLimiter) Reset(ctx context.Context, check RateLimitCheck) error { + if l == nil || l.store == nil { + return port.ErrRateLimitBackendUnavailable + } + if _, ok := l.policies[check.Policy]; !ok { + return port.ErrRateLimitBackendUnavailable + } + return l.store.Reset(ctx, l.keyFor(check)) +} + +func (l *RateLimiter) Backend() string { + if l == nil || l.store == nil { + return "unknown" + } + return l.store.Backend() +} + +func (l *RateLimiter) keyFor(check RateLimitCheck) string { + mac := hmac.New(sha256.New, l.key) + _, _ = mac.Write([]byte(check.Policy)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(check.Dimension)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(check.Value)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/platform/rate_limiter_test.go b/internal/platform/rate_limiter_test.go new file mode 100644 index 0000000..29fd5b5 --- /dev/null +++ b/internal/platform/rate_limiter_test.go @@ -0,0 +1,42 @@ +package platform + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" +) + +type recordingRateLimitStore struct { + keys []string + backend string +} + +func (s *recordingRateLimitStore) Allow(_ context.Context, key string, _ port.RateLimitPolicy) (port.RateLimitDecision, error) { + s.keys = append(s.keys, key) + return port.RateLimitDecision{Allowed: true}, nil +} +func (s *recordingRateLimitStore) Reset(_ context.Context, key string) error { + s.keys = append(s.keys, key) + return nil +} +func (s *recordingRateLimitStore) Backend() string { return s.backend } +func (s *recordingRateLimitStore) Close() error { return nil } + +func TestRateLimiterHMACProtectsKeys(t *testing.T) { + store := &recordingRateLimitStore{backend: "memory"} + limiter := NewRateLimiter(store, "secret", map[string]port.RateLimitPolicy{ + "login": {Name: "login", Limit: 1, Window: time.Minute}, + }) + value := "user@example.com" + if _, err := limiter.Allow(context.Background(), RateLimitCheck{ + Policy: "login", Dimension: "email", Value: value, + }); err != nil { + t.Fatal(err) + } + if len(store.keys) != 1 || strings.Contains(store.keys[0], value) { + t.Fatalf("stored key = %q; raw value leaked", store.keys) + } +} diff --git a/internal/platform/redis.go b/internal/platform/redis.go new file mode 100644 index 0000000..338b485 --- /dev/null +++ b/internal/platform/redis.go @@ -0,0 +1,11 @@ +package platform + +import "github.com/redis/go-redis/v9" + +func NewRedisClient(address, password string, database int) *redis.Client { + return redis.NewClient(&redis.Options{ + Addr: address, + Password: password, + DB: database, + }) +} diff --git a/migrations/000003_rate_limits.down.sql b/migrations/000003_rate_limits.down.sql new file mode 100644 index 0000000..e14776a --- /dev/null +++ b/migrations/000003_rate_limits.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS rate_limit_buckets; diff --git a/migrations/000003_rate_limits.up.sql b/migrations/000003_rate_limits.up.sql new file mode 100644 index 0000000..ccea3ba --- /dev/null +++ b/migrations/000003_rate_limits.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS rate_limit_buckets ( + key_hash TEXT PRIMARY KEY, + count BIGINT NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires_at + ON rate_limit_buckets(expires_at); From e60c4e9bf36826aeb0700db70c8d65fdd5de5997 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:30:15 +0700 Subject: [PATCH 2/8] feat(auth): enforce rate limits across authentication flows --- internal/adapter/inbound/http/auth_handler.go | 34 +++++ internal/adapter/inbound/http/handler.go | 7 +- .../adapter/inbound/http/oauth_handler.go | 6 + internal/adapter/inbound/http/rate_limit.go | 89 +++++++++++++ .../adapter/inbound/http/rate_limit_test.go | 122 ++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 internal/adapter/inbound/http/rate_limit.go create mode 100644 internal/adapter/inbound/http/rate_limit_test.go diff --git a/internal/adapter/inbound/http/auth_handler.go b/internal/adapter/inbound/http/auth_handler.go index 4b085b0..d4f870a 100644 --- a/internal/adapter/inbound/http/auth_handler.go +++ b/internal/adapter/inbound/http/auth_handler.go @@ -92,6 +92,12 @@ func (h *Handler) Register(c *gin.Context) { return } + if !h.allowRateLimit(c, + ipRateLimit("register_ip", c), + emailRateLimit("register_email", req.Email), + ) { + return + } user := domain.User{Email: req.Email, Password: req.Password} if err := h.Svc.Auth.Register(ctx, user); err != nil { h.auditFailure(c, platform.EventUserRegister, "", err, 0) @@ -126,6 +132,12 @@ func (h *Handler) Login(c *gin.Context) { return } + if !h.allowRateLimit(c, + ipRateLimit("login_ip", c), + emailRateLimit("login_account", req.Email), + ) { + return + } user := domain.User{ Email: req.Email, Password: req.Password, @@ -139,6 +151,7 @@ func (h *Handler) Login(c *gin.Context) { return } + h.resetRateLimit(c, emailRateLimit("login_account", req.Email)) h.auditSuccess(c, platform.EventAuthLogin, "", 0) setAccessCookie(c, accessToken) setRefreshCookie(c, refreshToken) @@ -195,6 +208,9 @@ func (h *Handler) Logout(c *gin.Context) { func (h *Handler) Refresh(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() + if !h.allowRateLimit(c, ipRateLimit("refresh_ip", c)) { + return + } refreshToken, err := c.Cookie("refresh_token") if err != nil || refreshToken == "" { authErr := fmt.Errorf("refresh token not found: %w", domain.ErrUnauthenticated) @@ -261,6 +277,12 @@ func (h *Handler) ResendVerifyEmail(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Email is required"}) return } + if !h.allowRateLimit(c, + ipRateLimit("verify_ip", c), + emailRateLimit("verify_email", email), + ) { + return + } if err := h.Svc.Auth.CreateVerifyEmail(ctx, email); err != nil { h.auditFailure(c, platform.EventAuthEmailVerification, "", err, 0) c.Error(err) @@ -289,6 +311,12 @@ func (h *Handler) ForgotPassword(c *gin.Context) { h.audit(c, platform.EventAuthPasswordResetRequest, platform.OutcomeFailure, platform.ReasonValidation, "", 0) return } + if !h.allowRateLimit(c, + ipRateLimit("recovery_ip", c), + emailRateLimit("recovery_email", req.Email), + ) { + return + } if err := h.Svc.Auth.ForgotPassword(ctx, req.Email); err != nil { h.auditFailure(c, platform.EventAuthPasswordResetRequest, "", err, 0) c.Error(err) @@ -317,6 +345,12 @@ func (h *Handler) ResetPassword(c *gin.Context) { h.audit(c, platform.EventAuthPasswordReset, platform.OutcomeFailure, platform.ReasonValidation, "", 0) return } + if !h.allowRateLimit(c, + ipRateLimit("recovery_ip", c), + tokenRateLimit("recovery_token", req.ID), + ) { + return + } if err := h.Svc.Auth.ResetPassword(ctx, req.ID, req.Password); err != nil { h.auditFailure(c, platform.EventAuthPasswordReset, "", err, 0) c.Error(err) diff --git a/internal/adapter/inbound/http/handler.go b/internal/adapter/inbound/http/handler.go index df0d2d4..37ccb8b 100644 --- a/internal/adapter/inbound/http/handler.go +++ b/internal/adapter/inbound/http/handler.go @@ -6,9 +6,10 @@ import ( ) type Handler struct { - Svc port.Service - Tokens port.TokenService // used by middleware - Audit *platform.AuditLogger + Svc port.Service + Tokens port.TokenService // used by middleware + Audit *platform.AuditLogger + RateLimiter *platform.RateLimiter } func NewHandler(svc port.Service, tokens port.TokenService) *Handler { diff --git a/internal/adapter/inbound/http/oauth_handler.go b/internal/adapter/inbound/http/oauth_handler.go index b761281..2faea85 100644 --- a/internal/adapter/inbound/http/oauth_handler.go +++ b/internal/adapter/inbound/http/oauth_handler.go @@ -20,6 +20,9 @@ import ( // @Failure 400 {object} ErrorResponse // @Router /api/oauth/{provider}/ [get] func (h *Handler) OAuthLogin(c *gin.Context) { + if !h.allowRateLimit(c, ipRateLimit("oauth_ip", c)) { + return + } h.Svc.OAuth.BeginAuth(c.Writer, c.Request, c.Param("provider")) } @@ -36,6 +39,9 @@ func (h *Handler) OAuthLogin(c *gin.Context) { func (h *Handler) OAuthCallback(c *gin.Context) { ctx, cancel := CtxWithTimeout(c) defer cancel() + if !h.allowRateLimit(c, ipRateLimit("oauth_ip", c)) { + return + } userData, err := h.Svc.OAuth.OAuthLogin(ctx, c.Writer, c.Request, c.Param("provider")) if err != nil { authErr := fmt.Errorf("oauth authentication failed: %w", domain.ErrUnauthenticated) diff --git a/internal/adapter/inbound/http/rate_limit.go b/internal/adapter/inbound/http/rate_limit.go new file mode 100644 index 0000000..af47d5a --- /dev/null +++ b/internal/adapter/inbound/http/rate_limit.go @@ -0,0 +1,89 @@ +package http + +import ( + "math" + "net/http" + "strconv" + "strings" + "time" + + inhttp "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" + "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/gin-gonic/gin" +) + +const backendRetryAfter = 30 * time.Second + +func ipRateLimit(policy string, c *gin.Context) platform.RateLimitCheck { + return platform.RateLimitCheck{Policy: policy, Dimension: "ip", Value: c.ClientIP()} +} + +func emailRateLimit(policy, email string) platform.RateLimitCheck { + return platform.RateLimitCheck{Policy: policy, Dimension: "email", Value: strings.ToLower(strings.TrimSpace(email))} +} + +func tokenRateLimit(policy, token string) platform.RateLimitCheck { + return platform.RateLimitCheck{Policy: policy, Dimension: "token", Value: token} +} + +func (h *Handler) allowRateLimit(c *gin.Context, checks ...platform.RateLimitCheck) bool { + if h.RateLimiter == nil { + return true + } + decision, err := h.RateLimiter.Allow(c.Request.Context(), checks...) + if err != nil { + h.logRateLimitEvent(c, platform.EventRateLimiterUnavailable, platform.OutcomeFailure, platform.ReasonBackendUnavailable, checks) + c.Header("Retry-After", retryAfterHeader(backendRetryAfter)) + c.Header("Cache-Control", "no-store") + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "authentication temporarily unavailable"}) + return false + } + if decision.Allowed { + return true + } + + h.logRateLimitEvent(c, platform.EventAuthRateLimited, platform.OutcomeDetected, platform.ReasonRateLimited, checks) + c.Header("Retry-After", retryAfterHeader(decision.RetryAfter)) + c.Header("Cache-Control", "no-store") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"}) + return false +} + +func (h *Handler) resetRateLimit(c *gin.Context, check platform.RateLimitCheck) { + if h.RateLimiter == nil { + return + } + if err := h.RateLimiter.Reset(c.Request.Context(), check); err != nil { + h.logRateLimitEvent(c, platform.EventRateLimiterUnavailable, platform.OutcomeFailure, platform.ReasonBackendUnavailable, []platform.RateLimitCheck{check}) + } +} + +func (h *Handler) logRateLimitEvent(c *gin.Context, event, outcome, reason string, checks []platform.RateLimitCheck) { + if h.Audit == nil || len(checks) == 0 { + return + } + h.Audit.Log(c.Request.Context(), platform.AuditEvent{ + Name: event, + Outcome: outcome, + RequestID: requestID(c), + Reason: reason, + Route: c.FullPath(), + Policy: checks[0].Policy, + Backend: h.RateLimiter.Backend(), + }) +} + +func requestID(c *gin.Context) string { + return inhttp.RequestIDFromContext(c.Request.Context()) +} + +func retryAfterHeader(duration time.Duration) string { + seconds := int64(math.Ceil(duration.Seconds())) + if seconds < 1 { + seconds = 1 + } + if seconds > 3600 { + seconds = 3600 + } + return strconv.FormatInt(seconds, 10) +} diff --git a/internal/adapter/inbound/http/rate_limit_test.go b/internal/adapter/inbound/http/rate_limit_test.go new file mode 100644 index 0000000..e8dc4db --- /dev/null +++ b/internal/adapter/inbound/http/rate_limit_test.go @@ -0,0 +1,122 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Jonathan0823/auth-go/internal/core/port" + "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/gin-gonic/gin" +) + +type fakeRateLimitStore struct { + decision port.RateLimitDecision + err error +} + +func (s fakeRateLimitStore) Allow(context.Context, string, port.RateLimitPolicy) (port.RateLimitDecision, error) { + return s.decision, s.err +} +func (fakeRateLimitStore) Reset(context.Context, string) error { return nil } +func (fakeRateLimitStore) Backend() string { return "memory" } +func (fakeRateLimitStore) Close() error { return nil } + +func TestRateLimitResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{RateLimiter: platform.NewRateLimiter( + fakeRateLimitStore{decision: port.RateLimitDecision{RetryAfter: 10 * time.Second}}, + "test-key", + map[string]port.RateLimitPolicy{"login_ip": {Name: "login_ip", Limit: 1, Window: time.Minute}}, + )} + router := gin.New() + router.GET("/", func(c *gin.Context) { + if handler.allowRateLimit(c, ipRateLimit("login_ip", c)) { + c.Status(http.StatusNoContent) + } + }) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429", response.Code) + } + if response.Header().Get("Retry-After") != "10" { + t.Fatalf("Retry-After = %q, want 10", response.Header().Get("Retry-After")) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", response.Header().Get("Cache-Control")) + } + if response.Body.String() != `{"error":"too many requests"}` { + t.Fatalf("body = %q", response.Body.String()) + } +} + +func TestRateLimitUsesDirectPeerWithoutTrustedProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + if err := router.SetTrustedProxies(nil); err != nil { + t.Fatal(err) + } + var clientIP string + router.GET("/", func(c *gin.Context) { + clientIP = c.ClientIP() + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.RemoteAddr = "192.0.2.10:1234" + request.Header.Set("X-Forwarded-For", "198.51.100.20") + router.ServeHTTP(httptest.NewRecorder(), request) + if clientIP != "192.0.2.10" { + t.Fatalf("client IP = %q, want direct peer", clientIP) + } +} + +func TestRateLimitUsesForwardedIPFromTrustedProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + if err := router.SetTrustedProxies([]string{"192.0.2.0/24"}); err != nil { + t.Fatal(err) + } + var clientIP string + router.GET("/", func(c *gin.Context) { + clientIP = c.ClientIP() + c.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.RemoteAddr = "192.0.2.10:1234" + request.Header.Set("X-Forwarded-For", "198.51.100.20") + router.ServeHTTP(httptest.NewRecorder(), request) + if clientIP != "198.51.100.20" { + t.Fatalf("client IP = %q, want forwarded client", clientIP) + } +} + +func TestRateLimitBackendFailureDoesNotLeakError(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{RateLimiter: platform.NewRateLimiter( + fakeRateLimitStore{err: errors.New("redis password leaked")}, + "test-key", + map[string]port.RateLimitPolicy{"login_ip": {Name: "login_ip", Limit: 1, Window: time.Minute}}, + )} + router := gin.New() + router.GET("/", func(c *gin.Context) { + if handler.allowRateLimit(c, ipRateLimit("login_ip", c)) { + c.Status(http.StatusNoContent) + } + }) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", response.Code) + } + if response.Body.String() != `{"error":"authentication temporarily unavailable"}` { + t.Fatalf("body = %q", response.Body.String()) + } +} From 354eef16c61aed40fbd5eba962d1d16022b41eb9 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:30:15 +0700 Subject: [PATCH 3/8] feat(observability): add rate-limit metrics and audit events --- internal/platform/audit.go | 25 +++++++++++-- internal/platform/metrics.go | 60 ++++++++++++++++++++++++++----- internal/platform/metrics_test.go | 29 +++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/internal/platform/audit.go b/internal/platform/audit.go index ad80beb..0cc2f29 100644 --- a/internal/platform/audit.go +++ b/internal/platform/audit.go @@ -15,6 +15,8 @@ const ( EventAuthPasswordReset = "auth.password_reset" EventAuthEmailVerification = "auth.email_verification" EventAuthOAuth = "auth.oauth" + EventAuthRateLimited = "auth.rate_limited" + EventRateLimiterUnavailable = "auth.rate_limiter_unavailable" OutcomeSuccess = "success" OutcomeFailure = "failure" @@ -30,6 +32,8 @@ const ( ReasonProviderError = "provider_error" ReasonReplayDetected = "replay_detected" ReasonInternal = "internal" + ReasonRateLimited = "rate_limited" + ReasonBackendUnavailable = "backend_unavailable" ) type AuditEvent struct { @@ -39,6 +43,9 @@ type AuditEvent struct { UserID int Provider string Reason string + Route string + Policy string + Backend string } type AuditLogger struct { @@ -51,7 +58,7 @@ func NewAuditLogger(logger *slog.Logger, metrics *Metrics) *AuditLogger { } func (a *AuditLogger) Log(ctx context.Context, event AuditEvent) { - if a == nil || a.logger == nil { + if a == nil { return } @@ -67,9 +74,23 @@ func (a *AuditLogger) Log(ctx context.Context, event AuditEvent) { if event.UserID > 0 { attrs = append(attrs, slog.Int("user_id", event.UserID)) } + if event.Route != "" { + attrs = append(attrs, Redact("route", normalizeRoute(event.Route))) + } + if event.Policy != "" { + attrs = append(attrs, Redact("policy", normalizePolicy(event.Policy))) + } + if event.Backend != "" { + attrs = append(attrs, Redact("backend", normalizeBackend(event.Backend))) + } - a.logger.LogAttrs(ctx, slog.LevelInfo, "security audit", attrs...) + if a.logger != nil { + a.logger.LogAttrs(ctx, slog.LevelInfo, "security audit", attrs...) + } if a.metrics != nil { a.metrics.RecordSecurityEvent(event.Name, event.Outcome, event.Reason, event.Provider) + if event.Name == EventAuthRateLimited || event.Name == EventRateLimiterUnavailable { + a.metrics.RecordRateLimitEvent(event.Route, event.Policy, event.Backend, event.Outcome, event.Reason) + } } } diff --git a/internal/platform/metrics.go b/internal/platform/metrics.go index 641ad49..515e656 100644 --- a/internal/platform/metrics.go +++ b/internal/platform/metrics.go @@ -10,11 +10,12 @@ import ( ) type Metrics struct { - Registry *prometheus.Registry - httpRequests *prometheus.CounterVec - httpDuration *prometheus.HistogramVec - httpInFlight prometheus.Gauge - securityEvents *prometheus.CounterVec + Registry *prometheus.Registry + httpRequests *prometheus.CounterVec + httpDuration *prometheus.HistogramVec + httpInFlight prometheus.Gauge + securityEvents *prometheus.CounterVec + rateLimitEvents *prometheus.CounterVec } func NewMetrics(pool *pgxpool.Pool) *Metrics { @@ -44,9 +45,14 @@ func NewMetrics(pool *pgxpool.Pool) *Metrics { Name: "security_events_total", Help: "Total number of security audit events.", }, []string{"event", "outcome", "reason", "provider"}), + rateLimitEvents: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "auth_go", + Name: "rate_limit_events_total", + Help: "Total number of rate-limit events.", + }, []string{"route", "policy", "backend", "outcome", "reason"}), } - metrics.Registry.MustRegister(metrics.httpRequests, metrics.httpDuration, metrics.httpInFlight, metrics.securityEvents) + metrics.Registry.MustRegister(metrics.httpRequests, metrics.httpDuration, metrics.httpInFlight, metrics.securityEvents, metrics.rateLimitEvents) if pool != nil { metrics.Registry.MustRegister(newPoolCollector(pool)) } @@ -79,11 +85,22 @@ func (m *Metrics) RecordSecurityEvent(event, outcome, reason, provider string) { ).Inc() } +func (m *Metrics) RecordRateLimitEvent(route, policy, backend, outcome, reason string) { + m.rateLimitEvents.WithLabelValues( + normalizeRoute(route), + normalizePolicy(policy), + normalizeBackend(backend), + normalizeOutcome(outcome), + normalizeReason(reason), + ).Inc() +} + func normalizeEvent(event string) string { switch event { case EventUserRegister, EventAuthLogin, EventAuthLogout, EventAuthRefresh, EventAuthRefreshReplay, EventAuthPasswordResetRequest, EventAuthPasswordReset, - EventAuthEmailVerification, EventAuthOAuth: + EventAuthEmailVerification, EventAuthOAuth, EventAuthRateLimited, + EventRateLimiterUnavailable: return event default: return "unknown" @@ -103,13 +120,40 @@ func normalizeReason(reason string) string { switch reason { case ReasonNone, ReasonInvalidCredentials, ReasonValidation, ReasonUnauthenticated, ReasonNotFound, ReasonConflict, ReasonInvalidToken, ReasonProviderError, - ReasonReplayDetected, ReasonInternal: + ReasonReplayDetected, ReasonInternal, ReasonRateLimited, ReasonBackendUnavailable: return reason default: return "unknown" } } +func normalizeRoute(route string) string { + switch route { + case "/api/auth/login", "/api/auth/register", "/api/auth/refresh", "/api/auth/forgot-password", "/api/auth/reset-password", "/api/auth/verify/email/resend", "/api/oauth/:provider/", "/api/oauth/:provider/callback": + return route + default: + return "unknown" + } +} + +func normalizePolicy(policy string) string { + switch policy { + case "login_ip", "login_account", "register_ip", "register_email", "recovery_ip", "recovery_email", "recovery_token", "refresh_ip", "verify_ip", "verify_email", "oauth_ip": + return policy + default: + return "unknown" + } +} + +func normalizeBackend(backend string) string { + switch backend { + case "memory", "redis", "postgres": + return backend + default: + return "unknown" + } +} + func normalizeProvider(provider string) string { switch provider { case "github", "google": diff --git a/internal/platform/metrics_test.go b/internal/platform/metrics_test.go index e65c0cd..219bfdd 100644 --- a/internal/platform/metrics_test.go +++ b/internal/platform/metrics_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "log/slog" "strings" "testing" @@ -13,6 +14,30 @@ func newTestLogger(output *bytes.Buffer) *slog.Logger { return slog.New(slog.NewJSONHandler(output, nil)) } +func TestAuditLoggerRecordsRateLimitMetric(t *testing.T) { + metrics := NewMetrics(nil) + logger := NewAuditLogger(slog.New(slog.NewJSONHandler(io.Discard, nil)), metrics) + logger.Log(context.Background(), AuditEvent{ + Name: EventAuthRateLimited, + Outcome: OutcomeDetected, + Route: "/api/auth/login", + Reason: ReasonRateLimited, + Policy: "login_ip", + Backend: "memory", + }) + + families, err := metrics.Registry.Gather() + if err != nil { + t.Fatal(err) + } + for _, family := range families { + if family.GetName() == "auth_go_rate_limit_events_total" && len(family.GetMetric()) == 1 { + return + } + } + t.Fatal("rate-limit metric was not recorded") +} + func TestAuditLoggerUsesSafeFields(t *testing.T) { var output bytes.Buffer logger := NewAuditLogger( @@ -26,6 +51,8 @@ func TestAuditLoggerUsesSafeFields(t *testing.T) { UserID: 42, Provider: "github", Reason: ReasonReplayDetected, + Policy: "login_ip", + Backend: "memory", }) var entry map[string]any @@ -39,6 +66,8 @@ func TestAuditLoggerUsesSafeFields(t *testing.T) { "user_id": float64(42), "provider": "github", "reason": ReasonReplayDetected, + "policy": "login_ip", + "backend": "memory", } { if entry[key] != want { t.Fatalf("%s = %v, want %v", key, entry[key], want) From 4afd40cffe8c6f5ab82fc32ff318b4f23cde4fa3 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:30:15 +0700 Subject: [PATCH 4/8] chore(infra): configure Redis-backed rate-limit tests --- .env.example | 21 +++++++++++++++++++++ .github/workflows/ci.yml | 15 +++++++++++++++ docker-compose.yml | 14 ++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/.env.example b/.env.example index 7b18ecd..3cfd608 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,27 @@ DOMAIN=localhost # Database DATABASE_URL=postgres://postgres:postgres@localhost:5432/auth_go?sslmode=disable +# Authentication rate limiting +RATE_LIMIT_BACKEND=memory +RATE_LIMIT_KEY=change-me-rate-limit-key +RATE_LIMIT_MAX_MEMORY_KEYS=10000 +RATE_LIMIT_ALLOW_MEMORY_PRODUCTION=false +REDIS_ADDR=localhost:6379 +REDIS_PASSWORD= +REDIS_DB=0 +TRUSTED_PROXIES= +RATE_LIMIT_LOGIN_IP=30/1m +RATE_LIMIT_LOGIN_ACCOUNT=5/15m +RATE_LIMIT_REGISTER_IP=5/1h +RATE_LIMIT_REGISTER_EMAIL=5/1h +RATE_LIMIT_RECOVERY_IP=10/1h +RATE_LIMIT_RECOVERY_EMAIL=3/1h +RATE_LIMIT_RECOVERY_TOKEN=3/1h +RATE_LIMIT_REFRESH_IP=30/1m +RATE_LIMIT_VERIFY_IP=10/1h +RATE_LIMIT_VERIFY_EMAIL=3/1h +RATE_LIMIT_OAUTH_IP=10/1m + # Auth JWT_ACCESS_SECRET=change-me REFRESH_TOKEN_HASH_KEY=change-me-too diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7a8be0..6954118 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,9 @@ jobs: - name: Test unit suite run: go test ./... + - name: Test race detector + run: go test -race ./... + integration: runs-on: ubuntu-latest services: @@ -128,6 +131,15 @@ jobs: --health-interval 3s --health-timeout 3s --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 3s + --health-timeout 3s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -159,6 +171,9 @@ jobs: - name: Run integration tests env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/auth_go?sslmode=disable + REDIS_ADDR: localhost:6379 + REDIS_PASSWORD: "" + REDIS_DB: "0" JWT_ACCESS_SECRET: ci-test-access-secret REFRESH_TOKEN_HASH_KEY: ci-test-refresh-hash-key ENVIRONMENT: test diff --git a/docker-compose.yml b/docker-compose.yml index 5fc064d..4603d41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,15 @@ services: timeout: 3s retries: 5 + redis: + image: redis:7-alpine + container_name: auth-go-redis + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 5 + auth-go: image: auth-go:tidy container_name: auth-go @@ -26,8 +35,13 @@ services: - .env.prod environment: DATABASE_URL: postgres://postgres:postgres@db:5432/auth_go?sslmode=disable + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: "" + REDIS_DB: "0" extra_hosts: - "host.docker.internal:host-gateway" depends_on: db: condition: service_healthy + redis: + condition: service_healthy From 14bd7b0cef29c982ce42ff760d4505077656a346 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:30:15 +0700 Subject: [PATCH 5/8] chore(sonar): add integration coverage reporting --- .gitignore | 1 + Makefile | 8 +++++++- README.md | 10 +++++++--- sonar-project.properties | 3 ++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 61ed9fe..0f25958 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ tasks/ .pi/ .scannerwork/ auth-go +coverage/ diff --git a/Makefile b/Makefile index 079f1aa..ddc20ef 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,10 @@ MIGRATE_DIR ?= migrations MIGRATE ?= migrate SWAG_VERSION ?= v1.16.6 REDOCLY_VERSION ?= v2.43.1 +COVERAGE_DIR ?= coverage +COVERAGE_FILE ?= $(COVERAGE_DIR)/coverage.out -.PHONY: build test integration vet run sqlc swagger swagger-validate migrate-up migrate-down migrate-down-1 fmt +.PHONY: build test coverage integration vet run sqlc swagger swagger-validate migrate-up migrate-down migrate-down-1 fmt build: go build ./cmd/auth-go @@ -17,6 +19,10 @@ build: test: go test ./... +coverage: migrate-up + mkdir -p "$(COVERAGE_DIR)" + go test -tags=integration ./... -covermode=atomic -coverprofile="$(COVERAGE_FILE)" + integration: $(MAKE) migrate-up go test -tags=integration ./... diff --git a/README.md b/README.md index 64efb65..0d6885e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ cd auth-go cp .env.example .env go mod download -docker compose up -d db +docker compose up -d db redis make migrate-up make run ``` @@ -67,6 +67,8 @@ EMAIL= PASSWORD= ``` +Authentication rate limiting supports `memory`, `redis`, and `postgres` backends. Memory is intended for development or an explicitly approved single-instance deployment; production should use Redis or PostgreSQL. Set `RATE_LIMIT_KEY` to a separate secret and configure `TRUSTED_PROXIES` only for known proxy networks. + `make` loads `.env` automatically. Use another file explicitly when needed: ```bash @@ -78,14 +80,15 @@ ENV_FILE=.env.prod make run ```bash make migrate-up # Apply migrations make test # Unit tests -make integration # Migrate + PostgreSQL integration tests +make integration # Migrate + PostgreSQL/Redis integration tests +make coverage # Integration tests + coverage/coverage.out make vet # Static checks make sqlc # Regenerate PostgreSQL code make swagger # Regenerate Swagger artifacts make swagger-validate # Validate the Swagger document ``` -Integration tests use the `integration` build tag and require PostgreSQL: +Integration tests use the `integration` build tag and require PostgreSQL. Redis-backed tests run when `REDIS_ADDR` is configured: ```bash go test -tags=integration ./... @@ -97,6 +100,7 @@ go test -tags=integration ./... - `GET /health/ready` reports database readiness with a bounded PostgreSQL ping. - `GET /metrics` exposes Prometheus metrics only when `ENABLE_METRICS=true`; it is an operational endpoint and is intentionally excluded from Swagger. - Security audit events are emitted as structured JSON logs with request IDs and safe categorical context. +- Rate-limit denials and backend failures use low-cardinality audit and Prometheus events without raw identifiers. Metrics labels use route templates and avoid user-controlled values. Restrict `/metrics` to trusted monitoring systems in production. Grafana, Loki, and tracing infrastructure are intentionally not bundled. diff --git a/sonar-project.properties b/sonar-project.properties index c79aa28..cb64ba0 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,4 @@ sonar.sources=. -sonar.test.inclusions=**/*.spec.ts,**/*_test.go +sonar.test.inclusions=**/*_test.go +sonar.go.coverage.reportPaths=coverage/coverage.out sonar.exclusions=**/node_modules/**,dist/**,coverage/**,.scannerwork/**,**/.env*,.next/**,.pi/**,build/**,out/**,.turbo/**,.vercel/** From c4ce57a36637aac88b58453d6c99c08e59924067 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:37:20 +0700 Subject: [PATCH 6/8] docs(config): document environment options --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 3cfd608..ce44a76 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,15 @@ # App +# Options: development, test, production. ENVIRONMENT=development +# Options: true or false; Swagger is disabled automatically in production. ENABLE_SWAGGER=true +# Options: true or false. ENABLE_METRICS=false PORT=8080 +# Comma-separated browser origins allowed by CORS. ALLOWED_ORIGINS=http://localhost:3000 BASE_URL=http://localhost:8080 +# Options: DEBUG, INFO, WARN, ERROR. WARNING is also accepted. LOG_LEVEL=INFO DOMAIN=localhost @@ -12,14 +17,22 @@ DOMAIN=localhost DATABASE_URL=postgres://postgres:postgres@localhost:5432/auth_go?sslmode=disable # Authentication rate limiting +# Options: memory, redis, postgres. RATE_LIMIT_BACKEND=memory RATE_LIMIT_KEY=change-me-rate-limit-key +# Positive integer; maximum number of keys held by the memory backend. RATE_LIMIT_MAX_MEMORY_KEYS=10000 +# Options: true or false; keep false in production unless explicitly approved. RATE_LIMIT_ALLOW_MEMORY_PRODUCTION=false +# Redis host and port, for example localhost:6379. REDIS_ADDR=localhost:6379 +# Leave blank when Redis authentication is disabled. REDIS_PASSWORD= +# Non-negative Redis logical database index. REDIS_DB=0 +# Comma-separated trusted IP addresses or CIDRs; blank trusts only the direct peer. TRUSTED_PROXIES= +# Policy format: /, for example 30/1m. RATE_LIMIT_LOGIN_IP=30/1m RATE_LIMIT_LOGIN_ACCOUNT=5/15m RATE_LIMIT_REGISTER_IP=5/1h From 304959736258e827beff916eeec649a5718bc6a0 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 11:44:37 +0700 Subject: [PATCH 7/8] refactor(ratelimit): reduce cognitive complexity --- .../adapter/outbound/ratelimit/postgres.go | 108 +++++++++++------- internal/platform/rate_limit_config.go | 31 +++-- 2 files changed, 89 insertions(+), 50 deletions(-) diff --git a/internal/adapter/outbound/ratelimit/postgres.go b/internal/adapter/outbound/ratelimit/postgres.go index ac1b351..fab46dc 100644 --- a/internal/adapter/outbound/ratelimit/postgres.go +++ b/internal/adapter/outbound/ratelimit/postgres.go @@ -26,12 +26,9 @@ func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { } func (s *PostgresStore) Allow(ctx context.Context, key string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { - if err := ctx.Err(); err != nil { + if err := s.validateAllow(ctx, policy); err != nil { return port.RateLimitDecision{}, err } - if s.pool == nil || policy.Limit < 1 || policy.Window <= 0 { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable - } now := time.Now().UTC() tx, err := s.pool.Begin(ctx) @@ -40,64 +37,95 @@ func (s *PostgresStore) Allow(ctx context.Context, key string, policy port.RateL } defer func() { _ = tx.Rollback(ctx) }() - if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, key); err != nil { + if err := prepareBucket(ctx, tx, key, now); err != nil { + return port.RateLimitDecision{}, err + } + count, started, err := findBucket(ctx, tx, key) + if errors.Is(err, pgx.ErrNoRows) { + return insertBucket(ctx, tx, key, now, policy) + } + if err != nil { return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } + + resetAt := started.Add(policy.Window) + if !now.Before(resetAt) { + return resetBucket(ctx, tx, key, now, policy) + } + if count >= policy.Limit { + return denyBucket(ctx, tx, resetAt.Sub(now)) + } + return incrementBucket(ctx, tx, key) +} + +func (s *PostgresStore) validateAllow(ctx context.Context, policy port.RateLimitPolicy) error { + if err := ctx.Err(); err != nil { + return err + } + if s.pool == nil || policy.Limit < 1 || policy.Window <= 0 { + return port.ErrRateLimitBackendUnavailable + } + return nil +} + +func prepareBucket(ctx context.Context, tx pgx.Tx, key string, now time.Time) error { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, key); err != nil { + return port.ErrRateLimitBackendUnavailable + } if _, err := tx.Exec(ctx, `DELETE FROM rate_limit_buckets WHERE key_hash = $1 AND expires_at <= $2`, key, now); err != nil { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable + return port.ErrRateLimitBackendUnavailable } + return nil +} +func findBucket(ctx context.Context, tx pgx.Tx, key string) (int, time.Time, error) { var count int var started time.Time - err = tx.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` SELECT count, window_start FROM rate_limit_buckets WHERE key_hash = $1 `, key).Scan(&count, &started) - if errors.Is(err, pgx.ErrNoRows) { - if _, err := tx.Exec(ctx, ` - INSERT INTO rate_limit_buckets (key_hash, count, window_start, expires_at) - VALUES ($1, 1, $2, $3) - `, key, now, now.Add(policy.Window)); err != nil { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable - } - if err := tx.Commit(ctx); err != nil { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable - } - return port.RateLimitDecision{Allowed: true}, nil - } - if err != nil { + return count, started, err +} + +func insertBucket(ctx context.Context, tx pgx.Tx, key string, now time.Time, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { + if _, err := tx.Exec(ctx, ` + INSERT INTO rate_limit_buckets (key_hash, count, window_start, expires_at) + VALUES ($1, 1, $2, $3) + `, key, now, now.Add(policy.Window)); err != nil { return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } + return commitDecision(ctx, tx, port.RateLimitDecision{Allowed: true}) +} - resetAt := started.Add(policy.Window) - if !now.Before(resetAt) { - if _, err := tx.Exec(ctx, ` - UPDATE rate_limit_buckets - SET count = 1, window_start = $2, expires_at = $3 - WHERE key_hash = $1 - `, key, now, now.Add(policy.Window)); err != nil { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable - } - if err := tx.Commit(ctx); err != nil { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable - } - return port.RateLimitDecision{Allowed: true}, nil - } - if count >= policy.Limit { - if err := tx.Commit(ctx); err != nil { - return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable - } - return port.RateLimitDecision{RetryAfter: resetAt.Sub(now)}, nil +func resetBucket(ctx context.Context, tx pgx.Tx, key string, now time.Time, policy port.RateLimitPolicy) (port.RateLimitDecision, error) { + if _, err := tx.Exec(ctx, ` + UPDATE rate_limit_buckets + SET count = 1, window_start = $2, expires_at = $3 + WHERE key_hash = $1 + `, key, now, now.Add(policy.Window)); err != nil { + return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } + return commitDecision(ctx, tx, port.RateLimitDecision{Allowed: true}) +} +func denyBucket(ctx context.Context, tx pgx.Tx, retryAfter time.Duration) (port.RateLimitDecision, error) { + return commitDecision(ctx, tx, port.RateLimitDecision{RetryAfter: retryAfter}) +} + +func incrementBucket(ctx context.Context, tx pgx.Tx, key string) (port.RateLimitDecision, error) { if _, err := tx.Exec(ctx, `UPDATE rate_limit_buckets SET count = count + 1 WHERE key_hash = $1`, key); err != nil { return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } + return commitDecision(ctx, tx, port.RateLimitDecision{Allowed: true}) +} + +func commitDecision(ctx context.Context, tx pgx.Tx, decision port.RateLimitDecision) (port.RateLimitDecision, error) { if err := tx.Commit(ctx); err != nil { return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable } - return port.RateLimitDecision{Allowed: true}, nil + return decision, nil } func (s *PostgresStore) Reset(ctx context.Context, key string) error { diff --git a/internal/platform/rate_limit_config.go b/internal/platform/rate_limit_config.go index def4a35..53d5e2b 100644 --- a/internal/platform/rate_limit_config.go +++ b/internal/platform/rate_limit_config.go @@ -60,6 +60,22 @@ func LoadRateLimitConfig() RateLimitConfig { } func (c RateLimitConfig) Validate(environment string) error { + if err := c.validateBackend(environment); err != nil { + return err + } + if c.Key == "" { + return fmt.Errorf("RATE_LIMIT_KEY is required") + } + if c.MaxMemoryKeys < 1 { + return fmt.Errorf("RATE_LIMIT_MAX_MEMORY_KEYS must be positive") + } + if len(c.invalidPolicyConfiguration) > 0 { + return fmt.Errorf("invalid rate-limit policies: %s", strings.Join(c.invalidPolicyConfiguration, ", ")) + } + return validateTrustedProxies(c.TrustedProxies) +} + +func (c RateLimitConfig) validateBackend(environment string) error { switch c.Backend { case "memory": if environment == "production" && !c.AllowMemoryInProduction { @@ -76,16 +92,11 @@ func (c RateLimitConfig) Validate(environment string) error { default: return fmt.Errorf("unsupported rate-limit backend %q", c.Backend) } - if c.Key == "" { - return fmt.Errorf("RATE_LIMIT_KEY is required") - } - if c.MaxMemoryKeys < 1 { - return fmt.Errorf("RATE_LIMIT_MAX_MEMORY_KEYS must be positive") - } - if len(c.invalidPolicyConfiguration) > 0 { - return fmt.Errorf("invalid rate-limit policies: %s", strings.Join(c.invalidPolicyConfiguration, ", ")) - } - for _, proxy := range c.TrustedProxies { + return nil +} + +func validateTrustedProxies(proxies []string) error { + for _, proxy := range proxies { if net.ParseIP(proxy) == nil { if _, _, err := net.ParseCIDR(proxy); err != nil { return fmt.Errorf("invalid trusted proxy address %q", proxy) From 845f96c9918ca5e2121d615ee78b619f70f964b3 Mon Sep 17 00:00:00 2001 From: Jonathan0823 Date: Fri, 31 Jul 2026 13:10:23 +0700 Subject: [PATCH 8/8] fix(infra): preserve production service configuration --- docker-compose.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4603d41..5176981 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,11 +33,6 @@ services: - "8080:8080" env_file: - .env.prod - environment: - DATABASE_URL: postgres://postgres:postgres@db:5432/auth_go?sslmode=disable - REDIS_ADDR: redis:6379 - REDIS_PASSWORD: "" - REDIS_DB: "0" extra_hosts: - "host.docker.internal:host-gateway" depends_on: