feat: api rate limits (global & auth specific)
This commit is contained in:
parent
09a4a74c42
commit
869f0887d8
@ -6,6 +6,7 @@ require (
|
|||||||
github.com/caarlos0/env/v10 v10.0.0
|
github.com/caarlos0/env/v10 v10.0.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.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-jwt/jwt/v5 v5.2.2
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@ -23,11 +24,13 @@ require (
|
|||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/lib/pq v1.10.9 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // 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
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
@ -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/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 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
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 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
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=
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"git.umbrella.haus/ae/qnote/internal/data"
|
"git.umbrella.haus/ae/qnote/internal/data"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
@ -85,17 +86,18 @@ type UserStore interface {
|
|||||||
// (especially in production) the `UserStore` and `TokenStore` will point to the same database
|
// (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.
|
// handler, but for code readability they should be kept in separate structs.
|
||||||
type authResource struct {
|
type authResource struct {
|
||||||
Config SvcConfig
|
Config SvcConfig
|
||||||
Users UserStore
|
RateLimiter *httprate.RateLimiter
|
||||||
Tokens TokenStore
|
Users UserStore
|
||||||
|
Tokens TokenStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs authResource) Routes() chi.Router {
|
func (rs authResource) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Public routes
|
// 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
|
r.Post("/signup", rs.Create) // POST /auth/signup - registration (rate-limited)
|
||||||
r.Post("/login", rs.Login) // POST /auth/login - login
|
r.Post("/login", rs.Login) // POST /auth/login - login (rate-limited)
|
||||||
|
|
||||||
// Protected routes (access token required)
|
// Protected routes (access token required)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@ -138,9 +140,9 @@ func (rs authResource) Routes() chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for new user creation. Will check the incoming JSON object's integrity, validate/normalize
|
// Rate-limited handler for new user creation. Will check the incoming JSON object's integrity,
|
||||||
// the username, and validate the password (check whether it's compromised via the HIBP API and
|
// validate/normalize the username, and validate the password (check whether it's compromised via
|
||||||
// calculate its entropy).
|
// the HIBP API and calculate its entropy).
|
||||||
func (rs authResource) Create(w http.ResponseWriter, r *http.Request) {
|
func (rs authResource) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
type request struct {
|
type request struct {
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
@ -153,6 +155,11 @@ func (rs authResource) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limiting with username as the key
|
||||||
|
if rs.RateLimiter.RespondOnLimit(w, r, *req.Username) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Username normalization (to lowercase)
|
// Username normalization (to lowercase)
|
||||||
normalizedUsername := normalizeUsername(*req.Username)
|
normalizedUsername := normalizeUsername(*req.Username)
|
||||||
if err := validateUsername(normalizedUsername); err != nil {
|
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.
|
// Rate-limited handler for logging in to an existing user account using a username-password
|
||||||
// Will check the incoming JSON object's integrity, use a normalized version of the username for a
|
// credentials pair. Will check the incoming JSON object's integrity, use a normalized version of
|
||||||
// database lookup, and compare the given password's hash against the one stored in the database.
|
// the username for a database lookup, and compare the given password's hash against the one stored
|
||||||
// By default only returns a fresh access tokens (and a refresh token as a httpOnly cookie), but
|
// in the database. By default only returns a fresh access tokens (and a refresh token as a httpOnly
|
||||||
// if the `includeUser` parameter is set to `true` the user DTO will also be included.
|
// 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) {
|
func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
type request struct {
|
type request struct {
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
@ -208,6 +215,11 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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))
|
user, err := rs.Users.GetUserByUsername(r.Context(), normalizeUsername(*req.Username))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusUnauthorized, "Invalid credentials")
|
respondError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||||
|
@ -2,15 +2,19 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.umbrella.haus/ae/qnote/internal/data"
|
"git.umbrella.haus/ae/qnote/internal/data"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const authRateLimit = 5 // req/min
|
||||||
|
|
||||||
type SvcConfig struct {
|
type SvcConfig struct {
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
CSRFSecret string
|
CSRFSecret string
|
||||||
@ -33,9 +37,10 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authRouter := authResource{
|
authRouter := authResource{
|
||||||
Config: config,
|
Config: config,
|
||||||
Users: q,
|
RateLimiter: httprate.NewRateLimiter(authRateLimit, time.Minute),
|
||||||
Tokens: q,
|
Users: q,
|
||||||
|
Tokens: q,
|
||||||
}
|
}
|
||||||
notesRouter := notesResource{
|
notesRouter := notesResource{
|
||||||
Config: config,
|
Config: config,
|
||||||
@ -46,6 +51,7 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
|
|||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
r.Use(loggerMiddleware(&log.Logger))
|
r.Use(loggerMiddleware(&log.Logger))
|
||||||
|
r.Use(httprate.LimitByIP(100, time.Minute)) // Base limit for all routes
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: config.allowedOrigins(),
|
AllowedOrigins: config.allowedOrigins(),
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user