From 869f0887d8634da20c2bfd8b48e406ad068fdb51 Mon Sep 17 00:00:00 2001 From: ae Date: Fri, 2 May 2025 20:53:55 +0300 Subject: [PATCH] feat: api rate limits (global & auth specific) --- server/go.mod | 3 +++ server/go.sum | 8 ++++++ server/internal/service/auth.go | 40 +++++++++++++++++++----------- server/internal/service/service.go | 12 ++++++--- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/server/go.mod b/server/go.mod index fb7c3d0..88e6eff 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,6 +6,7 @@ require ( github.com/caarlos0/env/v10 v10.0.0 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 + github.com/go-chi/httprate v0.15.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 @@ -23,11 +24,13 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/server/go.sum b/server/go.sum index 49f75c3..b31c07a 100644 --- a/server/go.sum +++ b/server/go.sum @@ -24,6 +24,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -56,6 +58,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -95,6 +99,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= diff --git a/server/internal/service/auth.go b/server/internal/service/auth.go index a5d8022..33cb4ae 100644 --- a/server/internal/service/auth.go +++ b/server/internal/service/auth.go @@ -14,6 +14,7 @@ import ( "git.umbrella.haus/ae/qnote/internal/data" "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/gorilla/csrf" @@ -85,17 +86,18 @@ type UserStore interface { // (especially in production) the `UserStore` and `TokenStore` will point to the same database // handler, but for code readability they should be kept in separate structs. type authResource struct { - Config SvcConfig - Users UserStore - Tokens TokenStore + Config SvcConfig + RateLimiter *httprate.RateLimiter + Users UserStore + Tokens TokenStore } func (rs authResource) Routes() chi.Router { r := chi.NewRouter() - // Public routes - r.Post("/signup", rs.Create) // POST /auth/signup - registration - r.Post("/login", rs.Login) // POST /auth/login - login + // Public routes (the only outside facing endpoints in the whole API with no requirement for auth) + r.Post("/signup", rs.Create) // POST /auth/signup - registration (rate-limited) + r.Post("/login", rs.Login) // POST /auth/login - login (rate-limited) // Protected routes (access token required) r.Group(func(r chi.Router) { @@ -138,9 +140,9 @@ func (rs authResource) Routes() chi.Router { return r } -// Handler for new user creation. Will check the incoming JSON object's integrity, validate/normalize -// the username, and validate the password (check whether it's compromised via the HIBP API and -// calculate its entropy). +// Rate-limited handler for new user creation. Will check the incoming JSON object's integrity, +// validate/normalize the username, and validate the password (check whether it's compromised via +// the HIBP API and calculate its entropy). func (rs authResource) Create(w http.ResponseWriter, r *http.Request) { type request struct { Username *string `json:"username"` @@ -153,6 +155,11 @@ func (rs authResource) Create(w http.ResponseWriter, r *http.Request) { return } + // Rate limiting with username as the key + if rs.RateLimiter.RespondOnLimit(w, r, *req.Username) { + return + } + // Username normalization (to lowercase) normalizedUsername := normalizeUsername(*req.Username) if err := validateUsername(normalizedUsername); err != nil { @@ -191,11 +198,11 @@ func (rs authResource) Create(w http.ResponseWriter, r *http.Request) { }) } -// Handler for logging in to an existing user account using a username-password credentials pair. -// Will check the incoming JSON object's integrity, use a normalized version of the username for a -// database lookup, and compare the given password's hash against the one stored in the database. -// By default only returns a fresh access tokens (and a refresh token as a httpOnly cookie), but -// if the `includeUser` parameter is set to `true` the user DTO will also be included. +// Rate-limited handler for logging in to an existing user account using a username-password +// credentials pair. Will check the incoming JSON object's integrity, use a normalized version of +// the username for a database lookup, and compare the given password's hash against the one stored +// in the database. By default only returns a fresh access tokens (and a refresh token as a httpOnly +// cookie), but if the `includeUser` parameter is set to `true` the user DTO will also be included. func (rs authResource) Login(w http.ResponseWriter, r *http.Request) { type request struct { Username *string `json:"username"` @@ -208,6 +215,11 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) { return } + // Rate limiting with username as the key + if rs.RateLimiter.RespondOnLimit(w, r, *req.Username) { + return + } + user, err := rs.Users.GetUserByUsername(r.Context(), normalizeUsername(*req.Username)) if err != nil { respondError(w, http.StatusUnauthorized, "Invalid credentials") diff --git a/server/internal/service/service.go b/server/internal/service/service.go index 52f1257..893edef 100644 --- a/server/internal/service/service.go +++ b/server/internal/service/service.go @@ -2,15 +2,19 @@ package service import ( "net/http" + "time" "git.umbrella.haus/ae/qnote/internal/data" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/go-chi/httprate" "github.com/jackc/pgx/v5" "github.com/rs/zerolog/log" ) +const authRateLimit = 5 // req/min + type SvcConfig struct { JWTSecret string CSRFSecret string @@ -33,9 +37,10 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error { } authRouter := authResource{ - Config: config, - Users: q, - Tokens: q, + Config: config, + RateLimiter: httprate.NewRateLimiter(authRateLimit, time.Minute), + Users: q, + Tokens: q, } notesRouter := notesResource{ Config: config, @@ -46,6 +51,7 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error { r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(loggerMiddleware(&log.Logger)) + r.Use(httprate.LimitByIP(100, time.Minute)) // Base limit for all routes r.Use(cors.Handler(cors.Options{ AllowedOrigins: config.allowedOrigins(), AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},