diff options
| -rw-r--r-- | backend/go.mod | 4 | ||||
| -rw-r--r-- | backend/go.sum | 4 | ||||
| -rw-r--r-- | backend/main.go | 6 | ||||
| -rw-r--r-- | backend/ratelimit/ratelimit.go | 73 |
4 files changed, 83 insertions, 4 deletions
diff --git a/backend/go.mod b/backend/go.mod index 2d38926..374c3df 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module albatross-2026-backend -go 1.23.6 +go 1.24.0 require ( github.com/getkin/kin-openapi v0.129.0 @@ -16,6 +16,7 @@ require ( github.com/oapi-codegen/runtime v1.1.1 github.com/sqlc-dev/sqlc v1.28.0 golang.org/x/crypto v0.36.0 + golang.org/x/time v0.14.0 ) require ( @@ -247,7 +248,6 @@ require ( golang.org/x/sys v0.31.0 // indirect golang.org/x/term v0.30.0 // indirect golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.31.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect diff --git a/backend/go.sum b/backend/go.sum index 601ee7a..cf2c2f3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -751,8 +751,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/backend/main.go b/backend/main.go index 1f48af0..40fb8f0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -5,17 +5,20 @@ import ( "fmt" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" oapimiddleware "github.com/oapi-codegen/echo-middleware" + "golang.org/x/time/rate" "albatross-2026-backend/admin" "albatross-2026-backend/api" "albatross-2026-backend/config" "albatross-2026-backend/db" "albatross-2026-backend/game" + "albatross-2026-backend/ratelimit" "albatross-2026-backend/taskqueue" ) @@ -66,7 +69,10 @@ func main() { gameHub := game.NewGameHub(queries, taskQueue, workerServer) + loginRL := ratelimit.NewIPRateLimiter(rate.Every(time.Minute/5), 5) + apiGroup := e.Group(conf.BasePath + "api") + apiGroup.Use(ratelimit.LoginRateLimitMiddleware(loginRL)) apiGroup.Use(oapimiddleware.OapiRequestValidator(openAPISpec)) apiHandler := api.NewHandler(queries, gameHub) api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil)) 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) + } + } +} |
