All checks were successful
check / check (push) Successful in 1m15s
Add a token-bucket rate limiter (golang.org/x/time/rate) that limits login attempts per client IP on POST /api/v1/login. Returns 429 Too Many Requests with a Retry-After header when the limit is exceeded. Configurable via LOGIN_RATE_LIMIT (requests/sec, default 1) and LOGIN_RATE_BURST (burst size, default 5). Stale per-IP entries are automatically cleaned up every 10 minutes. Only the login endpoint is rate-limited per sneak's instruction — session creation and registration use hashcash proof-of-work instead.
242 lines
4.6 KiB
Go
242 lines
4.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
)
|
|
|
|
const minPasswordLength = 8
|
|
|
|
// clientIP extracts the client IP address from the request.
|
|
// It checks X-Forwarded-For and X-Real-IP headers before
|
|
// falling back to RemoteAddr.
|
|
func clientIP(request *http.Request) string {
|
|
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
// X-Forwarded-For may contain a comma-separated list;
|
|
// the first entry is the original client.
|
|
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd // split into two parts
|
|
ip := strings.TrimSpace(parts[0])
|
|
|
|
if ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
|
|
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
|
|
return strings.TrimSpace(realIP)
|
|
}
|
|
|
|
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
|
if err != nil {
|
|
return request.RemoteAddr
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
// HandleRegister creates a new user with a password.
|
|
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
|
return func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
request.Body = http.MaxBytesReader(
|
|
writer, request.Body, hdlr.maxBodySize(),
|
|
)
|
|
|
|
hdlr.handleRegister(writer, request)
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) handleRegister(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
type registerRequest struct {
|
|
Nick string `json:"nick"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
var payload registerRequest
|
|
|
|
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 !validNickRe.MatchString(payload.Nick) {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid nick format",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(payload.Password) < minPasswordLength {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"password must be at least 8 characters",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.RegisterUser(
|
|
request.Context(),
|
|
payload.Nick,
|
|
payload.Password,
|
|
)
|
|
if err != nil {
|
|
hdlr.handleRegisterError(
|
|
writer, request, err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
"nick": payload.Nick,
|
|
"token": token,
|
|
}, http.StatusCreated)
|
|
}
|
|
|
|
func (hdlr *Handlers) handleRegisterError(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
err error,
|
|
) {
|
|
if db.IsUniqueConstraintError(err) {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"nick already taken",
|
|
http.StatusConflict,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.log.Error(
|
|
"register user failed", "error", err,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.LoginUser(
|
|
request.Context(),
|
|
payload.Nick,
|
|
payload.Password,
|
|
)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid credentials",
|
|
http.StatusUnauthorized,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.deliverMOTD(
|
|
request, clientID, sessionID, payload.Nick,
|
|
)
|
|
|
|
// Initialize channel state so the new client knows
|
|
// which channels the session already belongs to.
|
|
hdlr.initChannelState(
|
|
request, clientID, sessionID, payload.Nick,
|
|
)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
"nick": payload.Nick,
|
|
"token": token,
|
|
}, http.StatusOK)
|
|
}
|