feat: api rate limits (global & auth specific)

This commit is contained in:
ae 2025-05-02 20:53:55 +03:00
parent 09a4a74c42
commit 869f0887d8
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
4 changed files with 46 additions and 17 deletions

View File

@ -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

View File

@ -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=

View File

@ -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")

View File

@ -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"},