aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-13 23:31:19 +0900
committernsfisis <nsfisis@gmail.com>2026-02-13 23:31:19 +0900
commit7757f26295cbf19c4d6fa068e2cb6bdc2589d01a (patch)
tree48d1145bacad99018378f20aa9826b04e7fa2832 /backend
parent470b7235b80d082009ad350e2b33ef6637209e02 (diff)
downloadphperkaigi-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')
-rw-r--r--backend/go.mod4
-rw-r--r--backend/go.sum4
-rw-r--r--backend/main.go6
-rw-r--r--backend/ratelimit/ratelimit.go73
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)
+ }
+ }
+}