All checks were successful
check / check (push) Successful in 6s
## Summary Adds per-IP rate limiting to `POST /api/v1/login` to prevent brute-force password attacks. closes #35 ## What Changed ### New package: `internal/ratelimit/` A generic per-key token-bucket rate limiter built on `golang.org/x/time/rate`: - `New(ratePerSec, burst)` creates a limiter with automatic background cleanup of stale entries - `Allow(key)` checks if a request from the given key should be permitted - `Stop()` terminates the background sweep goroutine - Stale entries (unused for 15 minutes) are pruned every 10 minutes ### Login handler integration The login handler (`internal/handlers/auth.go`) now: 1. Extracts the client IP from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` 2. Checks the per-IP rate limiter before processing the login 3. Returns **429 Too Many Requests** with a `Retry-After: 1` header when the limit is exceeded ### Configuration Two new environment variables (via Viper): | Variable | Default | Description | |---|---|---| | `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | | `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | ### Scope Per [sneak's instruction](#35), only the login endpoint is rate-limited. Session creation and registration use hashcash proof-of-work instead. ## Tests - 6 unit tests for the `ratelimit` package (constructor, burst, burst exceeded, key isolation, key tracking, stop) - 2 integration tests in `api_test.go`: - `TestLoginRateLimitExceeded`: exhausts burst with rapid requests, verifies 429 response and `Retry-After` header - `TestLoginRateLimitAllowsNormalUse`: verifies normal login still works ## README - Added "Login Rate Limiting" subsection under "Rate Limiting & Abuse Prevention" - Added `LOGIN_RATE_LIMIT` and `LOGIN_RATE_BURST` to the Configuration table Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #78 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
183 lines
3.4 KiB
Go
183 lines
3.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
)
|
|
|
|
const minPasswordLength = 8
|
|
|
|
// HandleLogin authenticates a user with nick and password.
|
|
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
|
return func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
request.Body = http.MaxBytesReader(
|
|
writer, request.Body, hdlr.maxBodySize(),
|
|
)
|
|
|
|
hdlr.handleLogin(writer, request)
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) handleLogin(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
ip := clientIP(request)
|
|
|
|
if !hdlr.loginLimiter.Allow(ip) {
|
|
writer.Header().Set(
|
|
"Retry-After", "1",
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"too many login attempts, try again later",
|
|
http.StatusTooManyRequests,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Nick string `json:"nick"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
var payload loginRequest
|
|
|
|
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid request body",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
|
|
if payload.Nick == "" || payload.Password == "" {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"nick and password required",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.executeLogin(
|
|
writer, request, payload.Nick, payload.Password,
|
|
)
|
|
}
|
|
|
|
func (hdlr *Handlers) executeLogin(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
nick, password string,
|
|
) {
|
|
remoteIP := clientIP(request)
|
|
|
|
hostname := resolveHostname(
|
|
request.Context(), remoteIP,
|
|
)
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.LoginUser(
|
|
request.Context(),
|
|
nick, password,
|
|
remoteIP, hostname,
|
|
)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid credentials",
|
|
http.StatusUnauthorized,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.stats.IncrConnections()
|
|
|
|
hdlr.deliverMOTD(
|
|
request, clientID, sessionID, nick,
|
|
)
|
|
|
|
// Initialize channel state so the new client knows
|
|
// which channels the session already belongs to.
|
|
hdlr.initChannelState(
|
|
request, clientID, sessionID, nick,
|
|
)
|
|
|
|
hdlr.setAuthCookie(writer, request, token)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
"nick": nick,
|
|
}, http.StatusOK)
|
|
}
|
|
|
|
// handlePass handles the IRC PASS command to set a
|
|
// password on the authenticated session, enabling
|
|
// multi-client login via POST /api/v1/login.
|
|
func (hdlr *Handlers) handlePass(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
bodyLines func() []string,
|
|
) {
|
|
lines := bodyLines()
|
|
if len(lines) == 0 || lines[0] == "" {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNeedMoreParams, nick,
|
|
[]string{irc.CmdPass},
|
|
"Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
password := lines[0]
|
|
|
|
if len(password) < minPasswordLength {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNeedMoreParams, nick,
|
|
[]string{irc.CmdPass},
|
|
"Password must be at least 8 characters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
err := hdlr.params.Database.SetPassword(
|
|
request.Context(), sessionID, password,
|
|
)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"set password failed", "error", err,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|