2026-05-31 02:14:10 +03:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"net/http"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
2026-05-31 21:52:56 +03:00
|
|
|
|
|
|
|
|
"warpbox.dev/backend/libs/services"
|
2026-05-31 02:14:10 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const csrfCookieName = "warpbox_csrf"
|
|
|
|
|
|
|
|
|
|
type rateLimiter struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
records map[string]rateRecord
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type rateRecord struct {
|
|
|
|
|
StartedAt time.Time
|
|
|
|
|
Count int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newRateLimiter() *rateLimiter {
|
|
|
|
|
return &rateLimiter{records: make(map[string]rateRecord)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (l *rateLimiter) Allow(key string, limit int, window time.Duration, now time.Time) bool {
|
|
|
|
|
if limit <= 0 || window <= 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
record := l.records[key]
|
|
|
|
|
if record.StartedAt.IsZero() || now.Sub(record.StartedAt) >= window {
|
|
|
|
|
l.records[key] = rateRecord{StartedAt: now, Count: 1}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
record.Count++
|
|
|
|
|
l.records[key] = record
|
|
|
|
|
return record.Count <= limit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) csrfToken(w http.ResponseWriter, r *http.Request) string {
|
|
|
|
|
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
|
|
|
|
|
return cookie.Value
|
|
|
|
|
}
|
|
|
|
|
token := randomToken(32)
|
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
|
|
|
Name: csrfCookieName,
|
|
|
|
|
Value: token,
|
|
|
|
|
Path: "/",
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
Secure: r.TLS != nil,
|
|
|
|
|
Expires: time.Now().Add(12 * time.Hour),
|
|
|
|
|
})
|
|
|
|
|
return token
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
|
|
|
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
cookie, err := r.Cookie(csrfCookieName)
|
|
|
|
|
if err != nil || cookie.Value == "" || r.FormValue("csrf_token") != cookie.Value {
|
|
|
|
|
http.Error(w, "invalid form token", http.StatusForbidden)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func randomToken(byteCount int) string {
|
|
|
|
|
data := make([]byte, byteCount)
|
|
|
|
|
if _, err := rand.Read(data); err != nil {
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().String()))
|
|
|
|
|
}
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString(data)
|
|
|
|
|
}
|
2026-05-31 21:52:56 +03:00
|
|
|
|
|
|
|
|
func (a *App) recordLoginAbuse(r *http.Request, kind, detail string) {
|
|
|
|
|
if a.banService == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
settings, err := a.banService.Settings()
|
|
|
|
|
if err != nil || !settings.AutoBanEnabled {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
threshold := settings.UserLoginFailureThreshold
|
|
|
|
|
if kind == services.AbuseKindAdminLogin {
|
|
|
|
|
threshold = settings.AdminLoginFailureThreshold
|
|
|
|
|
}
|
|
|
|
|
ip := uploadClientIP(r)
|
|
|
|
|
result, err := a.banService.RecordAbuse(ip, kind, detail, threshold, time.Now().UTC())
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.logger.Error("login abuse event failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "kind", kind, "error", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if result.Enabled {
|
|
|
|
|
a.logger.Warn("login abuse recorded", "source", "ban", "severity", "warn", "code", 4304, "ip", ip, "kind", kind, "count", result.Event.Count)
|
|
|
|
|
}
|
|
|
|
|
if result.Triggered {
|
|
|
|
|
a.logger.Warn("ip auto-banned for login abuse", "source", "ban", "severity", "warn", "code", 4305, "ip", ip, "kind", kind, "ban_id", result.Ban.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|