diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-13 23:31:19 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-13 23:31:19 +0900 |
| commit | 7757f26295cbf19c4d6fa068e2cb6bdc2589d01a (patch) | |
| tree | 48d1145bacad99018378f20aa9826b04e7fa2832 /backend/ratelimit | |
| parent | 470b7235b80d082009ad350e2b33ef6637209e02 (diff) | |
| download | phperkaigi-2026-albatross-7757f26295cbf19c4d6fa068e2cb6bdc2589d01a.tar.gz phperkaigi-2026-albatross-7757f26295cbf19c4d6fa068e2cb6bdc2589d01a.tar.zst phperkaigi-2026-albatross-7757f26295cbf19c4d6fa068e2cb6bdc2589d01a.zip | |
feat(auth): add login rate limiting per IP
Prevent brute-force attacks by limiting POST /login to 5 requests per
minute per IP address using golang.org/x/time/rate. Unused entries are
cleaned up after 10 minutes of inactivity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend/ratelimit')
| -rw-r--r-- | backend/ratelimit/ratelimit.go | 73 |
1 files changed, 73 insertions, 0 deletions
diff --git a/backend/ratelimit/ratelimit.go b/backend/ratelimit/ratelimit.go new file mode 100644 index 0000000..3200907 --- /dev/null +++ b/backend/ratelimit/ratelimit.go @@ -0,0 +1,73 @@ +package ratelimit + +import ( + "net/http" + "strings" + "sync" + "time" + + "github.com/labstack/echo/v4" + "golang.org/x/time/rate" +) + +type entry struct { + limiter *rate.Limiter + lastSeen time.Time +} + +type IPRateLimiter struct { + entries sync.Map + rate rate.Limit + burst int +} + +func NewIPRateLimiter(r rate.Limit, burst int) *IPRateLimiter { + rl := &IPRateLimiter{ + rate: r, + burst: burst, + } + go rl.cleanup() + return rl +} + +func (rl *IPRateLimiter) getLimiter(ip string) *rate.Limiter { + now := time.Now() + if v, ok := rl.entries.Load(ip); ok { + e := v.(*entry) + e.lastSeen = now + return e.limiter + } + limiter := rate.NewLimiter(rl.rate, rl.burst) + rl.entries.Store(ip, &entry{limiter: limiter, lastSeen: now}) + return limiter +} + +func (rl *IPRateLimiter) cleanup() { + for { + time.Sleep(10 * time.Minute) + rl.entries.Range(func(key, value any) bool { + e := value.(*entry) + if time.Since(e.lastSeen) > 10*time.Minute { + rl.entries.Delete(key) + } + return true + }) + } +} + +func LoginRateLimitMiddleware(rl *IPRateLimiter) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if c.Request().Method != http.MethodPost || !strings.HasSuffix(c.Path(), "/login") { + return next(c) + } + ip := c.RealIP() + if !rl.getLimiter(ip).Allow() { + return c.JSON(http.StatusTooManyRequests, map[string]string{ + "message": "Too many login attempts. Please try again later.", + }) + } + return next(c) + } + } +} |
