@@ -42,6 +42,11 @@ type server struct {
4242 hub * Hub
4343 srv * http.Server
4444 router chi.Router
45+
46+ // Rate limiters for different endpoint tiers.
47+ authRateLimiter * IPRateLimiter
48+ publicRateLimiter * IPRateLimiter
49+ authenticatedRateLimiter * IPRateLimiter
4550}
4651
4752// Ensure server implements Server.
@@ -63,6 +68,19 @@ func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q que
6368 hub : hub ,
6469 }
6570
71+ // Initialize rate limiters if enabled.
72+ if cfg .Server .RateLimit .Enabled {
73+ s .authRateLimiter = NewIPRateLimiter (cfg .Server .RateLimit .Auth .RequestsPerMinute )
74+ s .publicRateLimiter = NewIPRateLimiter (cfg .Server .RateLimit .Public .RequestsPerMinute )
75+ s .authenticatedRateLimiter = NewIPRateLimiter (cfg .Server .RateLimit .Authenticated .RequestsPerMinute )
76+
77+ log .WithFields (logrus.Fields {
78+ "auth_rpm" : cfg .Server .RateLimit .Auth .RequestsPerMinute ,
79+ "public_rpm" : cfg .Server .RateLimit .Public .RequestsPerMinute ,
80+ "authenticated_rpm" : cfg .Server .RateLimit .Authenticated .RequestsPerMinute ,
81+ }).Info ("Rate limiting enabled" )
82+ }
83+
6684 // Set up callback to broadcast job state changes via WebSocket.
6785 q .SetJobChangeCallback (func (job * store.Job ) {
6886 hub .BroadcastJobState (job )
@@ -150,29 +168,54 @@ func (s *server) setupRouter() {
150168 r .Use (corsMiddleware (s .cfg .Server .CORSOrigins ))
151169 }
152170
153- // Health check (public).
154- r .Get ("/health" , s .handleHealth )
171+ // Public endpoints with public rate limit.
172+ r .Group (func (r chi.Router ) {
173+ if s .publicRateLimiter != nil {
174+ r .Use (s .publicRateLimiter .Middleware )
175+ }
176+
177+ // Health check (public).
178+ r .Get ("/health" , s .handleHealth )
155179
156- // Metrics endpoint (public).
157- r .Handle ("/metrics" , promhttp .Handler ())
180+ // Metrics endpoint (public).
181+ r .Handle ("/metrics" , promhttp .Handler ())
182+ })
158183
159184 // API v1.
160185 r .Route ("/api/v1" , func (r chi.Router ) {
161- // OpenAPI spec (public).
162- r .Get ("/openapi.json" , s .handleOpenAPISpec )
186+ // OpenAPI spec (public rate limit).
187+ r .Group (func (r chi.Router ) {
188+ if s .publicRateLimiter != nil {
189+ r .Use (s .publicRateLimiter .Middleware )
190+ }
191+ r .Get ("/openapi.json" , s .handleOpenAPISpec )
192+ })
163193
164- // Auth routes (public).
165- r .Post ("/auth/login" , s .handleLogin )
166- r .Get ("/auth/github" , s .handleGitHubAuth )
167- r .Get ("/auth/github/callback" , s .handleGitHubCallback )
168- r .Post ("/auth/exchange" , s .handleExchangeCode )
194+ // Auth routes with strict rate limit.
195+ r .Group (func (r chi.Router ) {
196+ if s .authRateLimiter != nil {
197+ r .Use (s .authRateLimiter .Middleware )
198+ }
199+ r .Post ("/auth/login" , s .handleLogin )
200+ r .Get ("/auth/github" , s .handleGitHubAuth )
201+ r .Get ("/auth/github/callback" , s .handleGitHubCallback )
202+ r .Post ("/auth/exchange" , s .handleExchangeCode )
203+ })
169204
170- // WebSocket (authentication handled in handler).
171- r .Get ("/ws" , s .handleWebSocket )
205+ // WebSocket (authentication handled in handler, uses authenticated rate limit).
206+ r .Group (func (r chi.Router ) {
207+ if s .authenticatedRateLimiter != nil {
208+ r .Use (s .authenticatedRateLimiter .Middleware )
209+ }
210+ r .Get ("/ws" , s .handleWebSocket )
211+ })
172212
173- // Protected routes.
213+ // Protected routes with authenticated rate limit .
174214 r .Group (func (r chi.Router ) {
175215 r .Use (auth .AuthMiddleware (s .auth ))
216+ if s .authenticatedRateLimiter != nil {
217+ r .Use (s .authenticatedRateLimiter .Middleware )
218+ }
176219
177220 // Auth (authenticated).
178221 r .Post ("/auth/logout" , s .handleLogout )
@@ -303,6 +346,11 @@ type HealthResponse struct {
303346 Status string `json:"status" example:"ok"`
304347}
305348
349+ // RateLimitErrorResponse is returned when rate limit is exceeded.
350+ type RateLimitErrorResponse struct {
351+ Error string `json:"error" example:"rate limit exceeded"`
352+ }
353+
306354// handleOpenAPISpec godoc
307355//
308356// @Summary OpenAPI specification
@@ -325,6 +373,7 @@ func (s *server) handleOpenAPISpec(w http.ResponseWriter, _ *http.Request) {
325373// @Tags system
326374// @Produce json
327375// @Success 200 {object} HealthResponse
376+ // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
328377// @Router /health [get]
329378func (s * server ) handleHealth (w http.ResponseWriter , _ * http.Request ) {
330379 s .writeJSON (w , http .StatusOK , HealthResponse {Status : "ok" })
@@ -339,6 +388,7 @@ func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) {
339388// @Produce json
340389// @Success 200 {object} SystemStatusResponse
341390// @Failure 401 {object} ErrorResponse
391+ // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
342392// @Router /status [get]
343393func (s * server ) handleStatus (w http.ResponseWriter , r * http.Request ) {
344394 ctx := r .Context ()
@@ -1685,6 +1735,7 @@ type LoginResponse struct {
16851735// @Success 200 {object} LoginResponse
16861736// @Failure 400 {object} ErrorResponse
16871737// @Failure 401 {object} ErrorResponse
1738+ // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
16881739// @Router /auth/login [post]
16891740func (s * server ) handleLogin (w http.ResponseWriter , r * http.Request ) {
16901741 var req LoginRequest
@@ -1798,6 +1849,7 @@ func (s *server) handleMe(w http.ResponseWriter, r *http.Request) {
17981849// @Param state query string false "OAuth state for CSRF protection"
17991850// @Success 307 "Redirect to GitHub"
18001851// @Failure 404 {object} ErrorResponse "GitHub auth not enabled"
1852+ // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
18011853// @Router /auth/github [get]
18021854func (s * server ) handleGitHubAuth (w http.ResponseWriter , r * http.Request ) {
18031855 if ! s .cfg .Auth .GitHub .Enabled {
@@ -1833,6 +1885,7 @@ func (s *server) handleGitHubAuth(w http.ResponseWriter, r *http.Request) {
18331885// @Failure 400 {object} ErrorResponse
18341886// @Failure 401 {object} ErrorResponse
18351887// @Failure 404 {object} ErrorResponse "GitHub auth not enabled"
1888+ // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
18361889// @Router /auth/github/callback [get]
18371890func (s * server ) handleGitHubCallback (w http.ResponseWriter , r * http.Request ) {
18381891 if ! s .cfg .Auth .GitHub .Enabled {
@@ -1926,6 +1979,19 @@ type exchangeCodeRequest struct {
19261979 Code string `json:"code"`
19271980}
19281981
1982+ // handleExchangeCode godoc
1983+ //
1984+ // @Summary Exchange auth code for token
1985+ // @Description Exchanges a one-time authorization code for a session token
1986+ // @Tags auth
1987+ // @Accept json
1988+ // @Produce json
1989+ // @Param body body exchangeCodeRequest true "Auth code"
1990+ // @Success 200 {object} LoginResponse
1991+ // @Failure 400 {object} ErrorResponse
1992+ // @Failure 401 {object} ErrorResponse
1993+ // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
1994+ // @Router /auth/exchange [post]
19291995func (s * server ) handleExchangeCode (w http.ResponseWriter , r * http.Request ) {
19301996 var req exchangeCodeRequest
19311997 if err := json .NewDecoder (r .Body ).Decode (& req ); err != nil {
0 commit comments