package auth import ( "net/http" "net/url" "sync" "time" "gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers" "github.com/gin-gonic/gin" ) // CSRFHeader must be sent with every state-changing cookie-authenticated // request. Cross-site forms cannot set custom headers. const CSRFHeader = "X-Admin-CSRF" // RequireSecret guards the /back endpoints used by scrapers and indexers. func RequireSecret(c *gin.Context) { if initializers.SecretKey == "" { c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "backend auth not configured"}) return } if !SecretEqual(c.GetHeader("X-SECRET-KEY"), initializers.SecretKey) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) return } } // RequireAdminHeader is the legacy admin check: X-ADMIN-KEY only, 403 on failure. func RequireAdminHeader(c *gin.Context) { if !SecretEqual(c.GetHeader("X-ADMIN-KEY"), initializers.AdminSecret) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) return } c.Set("adminAuth", "header") } // RequireAdmin accepts either the X-ADMIN-KEY header (scripts) or a valid // session cookie (dashboard). Cookie requests that change state must also carry // the CSRF header and, when present, a same-host Origin. func RequireAdmin(c *gin.Context) { if initializers.AdminSecret == "" { c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "admin disabled: ADMIN_SECRET not set"}) return } if key := c.GetHeader("X-ADMIN-KEY"); key != "" { if !SecretEqual(key, initializers.AdminSecret) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin key"}) return } c.Set("adminAuth", "header") return } cookie, err := c.Cookie(CookieName) if err != nil || cookie == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } exp, ok := VerifyToken(cookie, time.Now()) if !ok { ClearSessionCookie(c) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) return } if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { if c.GetHeader(CSRFHeader) != "1" || !sameOrigin(c) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "csrf check failed"}) return } } c.Set("adminAuth", "cookie") c.Set("adminSessionExp", exp) } func sameOrigin(c *gin.Context) bool { origin := c.GetHeader("Origin") if origin == "" { return true } u, err := url.Parse(origin) if err != nil { return false } return u.Host == c.Request.Host } // LoginLimiter throttles failed logins per client IP. type LoginLimiter struct { mu sync.Mutex failures map[string][]time.Time max int window time.Duration } func NewLoginLimiter(max int, window time.Duration) *LoginLimiter { return &LoginLimiter{failures: make(map[string][]time.Time), max: max, window: window} } func (l *LoginLimiter) prune(ip string, now time.Time) []time.Time { kept := l.failures[ip][:0] for _, t := range l.failures[ip] { if now.Sub(t) < l.window { kept = append(kept, t) } } if len(kept) == 0 { delete(l.failures, ip) return nil } l.failures[ip] = kept return kept } // Blocked reports whether the IP has used up its failed attempts. func (l *LoginLimiter) Blocked(ip string) bool { l.mu.Lock() defer l.mu.Unlock() return len(l.prune(ip, time.Now())) >= l.max } func (l *LoginLimiter) Fail(ip string) { l.mu.Lock() defer l.mu.Unlock() now := time.Now() l.failures[ip] = append(l.prune(ip, now), now) // keep the map bounded if someone sprays from many addresses if len(l.failures) > 10000 { for k := range l.failures { l.prune(k, now) } } } func (l *LoginLimiter) Reset(ip string) { l.mu.Lock() defer l.mu.Unlock() delete(l.failures, ip) }