From f4701b0d243696c2337d2992a68c0416607d4df7 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Fri, 6 Feb 2026 12:09:02 -0600 Subject: [PATCH] Ban enforcement: immediate session kill, IP logging, login/register/middleware checks --- go-backend/cmd/api/main.go | 4 +- go-backend/internal/handlers/auth_handler.go | 40 +++++++++++++++++++ go-backend/internal/handlers/post_handler.go | 5 +-- go-backend/internal/middleware/auth.go | 37 ++++++++++++++++- go-backend/internal/models/user.go | 2 + .../internal/repository/user_repository.go | 18 +++++++++ .../internal/services/content_filter.go | 16 +++++++- go-backend/scripts/create_banned_ips.sql | 11 +++++ 8 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 go-backend/scripts/create_banned_ips.sql diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 51b7357..452a142 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -191,7 +191,7 @@ func main() { } authorized := v1.Group("") - authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret, dbPool)) { authorized.GET("/profiles/:id", userHandler.GetProfile) authorized.GET("/profile", userHandler.GetProfile) @@ -350,7 +350,7 @@ func main() { // Admin Panel API (requires auth + admin role) // ────────────────────────────────────────────── admin := r.Group("/api/v1/admin") - admin.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + admin.Use(middleware.AuthMiddleware(cfg.JWTSecret, dbPool)) admin.Use(middleware.AdminMiddleware(dbPool)) { // Dashboard diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index 54b4bbe..d09b201 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -75,6 +75,14 @@ func (h *AuthHandler) Register(c *gin.Context) { return } + // Check if this IP is banned (ban evasion prevention) + ipBanned, _ := h.repo.IsIPBanned(c.Request.Context(), remoteIP) + if ipBanned { + log.Printf("[Auth] Registration blocked for banned IP: %s", remoteIP) + c.JSON(http.StatusForbidden, gin.H{"error": "Registration is not available from this network."}) + return + } + existingUser, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email) if err == nil && existingUser != nil { c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) @@ -178,6 +186,14 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // Check if this IP is banned (ban evasion prevention) + ipBanned, _ := h.repo.IsIPBanned(c.Request.Context(), remoteIP) + if ipBanned { + log.Printf("[Auth] Login blocked for banned IP: %s", remoteIP) + c.JSON(http.StatusForbidden, gin.H{"error": "Access is not available from this network."}) + return + } + user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email) if err != nil { log.Printf("[Auth] Login failed for %s: user not found", req.Email) @@ -195,6 +211,14 @@ func (h *AuthHandler) Login(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Email verification required", "code": "verify_email"}) return } + if user.Status == models.UserStatusBanned { + c.JSON(http.StatusForbidden, gin.H{"error": "This account has been permanently suspended for violating our community guidelines.", "code": "banned"}) + return + } + if user.Status == models.UserStatusSuspended { + c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"}) + return + } if user.Status == models.UserStatusDeactivated { c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"}) return @@ -359,6 +383,22 @@ func (h *AuthHandler) RefreshSession(c *gin.Context) { return } + // Check if user is banned/suspended before issuing new tokens + rtUser, err := h.repo.GetUserByID(c.Request.Context(), rt.UserID.String()) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + return + } + if rtUser.Status == models.UserStatusBanned { + _ = h.repo.RevokeAllUserTokens(c.Request.Context(), rt.UserID.String()) + c.JSON(http.StatusForbidden, gin.H{"error": "This account has been permanently suspended.", "code": "banned"}) + return + } + if rtUser.Status == models.UserStatusSuspended { + c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended.", "code": "suspended"}) + return + } + _ = h.repo.RevokeRefreshToken(c.Request.Context(), req.RefreshToken) newAccessToken, err := h.generateToken(rt.UserID) diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index e5b94e6..c38ad04 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -61,8 +61,7 @@ func (h *PostHandler) CreateComment(c *gin.Context) { if h.contentFilter != nil { result := h.contentFilter.CheckContent(req.Body) if result.Blocked { - // Record strike - strikeCount, consequence, _ := h.contentFilter.RecordStrike(c.Request.Context(), userID, result.Category, req.Body) + strikeCount, consequence, _ := h.contentFilter.RecordStrikeWithIP(c.Request.Context(), userID, result.Category, req.Body, c.ClientIP()) c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": result.Message, "blocked": true, @@ -208,7 +207,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) { if h.contentFilter != nil { result := h.contentFilter.CheckContent(req.Body) if result.Blocked { - strikeCount, consequence, _ := h.contentFilter.RecordStrike(c.Request.Context(), userID, result.Category, req.Body) + strikeCount, consequence, _ := h.contentFilter.RecordStrikeWithIP(c.Request.Context(), userID, result.Category, req.Body, c.ClientIP()) c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": result.Message, "blocked": true, diff --git a/go-backend/internal/middleware/auth.go b/go-backend/internal/middleware/auth.go index 07a7f6c..adb5d29 100644 --- a/go-backend/internal/middleware/auth.go +++ b/go-backend/internal/middleware/auth.go @@ -1,12 +1,15 @@ package middleware import ( + "context" "fmt" "net/http" "strings" + "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" ) @@ -37,7 +40,12 @@ func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, er return userID, claims, nil } -func AuthMiddleware(jwtSecret string) gin.HandlerFunc { +func AuthMiddleware(jwtSecret string, pool ...*pgxpool.Pool) gin.HandlerFunc { + var dbPool *pgxpool.Pool + if len(pool) > 0 { + dbPool = pool[0] + } + return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { @@ -63,6 +71,33 @@ func AuthMiddleware(jwtSecret string) gin.HandlerFunc { return } + // Check ban/suspend status from DB (immediate enforcement) + if dbPool != nil { + var status string + var suspendedUntil *time.Time + err := dbPool.QueryRow(context.Background(), + `SELECT status, suspended_until FROM users WHERE id = $1::uuid`, userID, + ).Scan(&status, &suspendedUntil) + if err == nil { + if status == "banned" { + c.JSON(http.StatusForbidden, gin.H{"error": "This account has been permanently suspended.", "code": "banned"}) + c.Abort() + return + } + if status == "suspended" { + if suspendedUntil != nil && time.Now().After(*suspendedUntil) { + // Suspension expired — reactivate + dbPool.Exec(context.Background(), + `UPDATE users SET status = 'active', suspended_until = NULL WHERE id = $1::uuid`, userID) + } else { + c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended.", "code": "suspended"}) + c.Abort() + return + } + } + } + } + // Store user ID and claims in context c.Set("user_id", userID) c.Set("claims", claims) diff --git a/go-backend/internal/models/user.go b/go-backend/internal/models/user.go index f33e4d6..14d095a 100644 --- a/go-backend/internal/models/user.go +++ b/go-backend/internal/models/user.go @@ -12,6 +12,8 @@ const ( UserStatusPending UserStatus = "pending" UserStatusActive UserStatus = "active" UserStatusDeactivated UserStatus = "deactivated" + UserStatusBanned UserStatus = "banned" + UserStatusSuspended UserStatus = "suspended" ) type User struct { diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index 4498813..3e0f8ef 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -1302,3 +1302,21 @@ func (r *UserRepository) ExportUserData(ctx context.Context, userID string) (*Us return export, nil } + +// BanIP records an IP address as banned (used when a user is banned to prevent evasion) +func (r *UserRepository) BanIP(ctx context.Context, ipAddress string, userID string, reason string) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO banned_ips (ip_address, user_id, reason, banned_at) + VALUES ($1, $2::uuid, $3, NOW()) + `, ipAddress, userID, reason) + return err +} + +// IsIPBanned checks if an IP address has been banned +func (r *UserRepository) IsIPBanned(ctx context.Context, ipAddress string) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM banned_ips WHERE ip_address = $1) + `, ipAddress).Scan(&exists) + return exists, err +} diff --git a/go-backend/internal/services/content_filter.go b/go-backend/internal/services/content_filter.go index eef6299..8e65be8 100644 --- a/go-backend/internal/services/content_filter.go +++ b/go-backend/internal/services/content_filter.go @@ -133,6 +133,11 @@ func (cf *ContentFilter) CheckContent(text string) *ContentCheckResult { // 5 strikes: 7-day suspension // 7+ strikes: permanent ban func (cf *ContentFilter) RecordStrike(ctx context.Context, userID uuid.UUID, category, content string) (int, string, error) { + return cf.RecordStrikeWithIP(ctx, userID, category, content, "") +} + +// RecordStrikeWithIP records a strike and logs the IP address for ban evasion prevention. +func (cf *ContentFilter) RecordStrikeWithIP(ctx context.Context, userID uuid.UUID, category, content, clientIP string) (int, string, error) { // Insert strike _, err := cf.pool.Exec(ctx, ` INSERT INTO content_strikes (user_id, category, content_snippet, created_at) @@ -158,7 +163,16 @@ func (cf *ContentFilter) RecordStrike(ctx context.Context, userID uuid.UUID, cat case count >= 7: consequence = "ban" cf.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1`, userID) - fmt.Printf("Content filter: user %s BANNED (%d strikes)\n", userID, count) + // Revoke ALL refresh tokens immediately so the user is logged out + cf.pool.Exec(ctx, `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1`, userID) + // Log IP for ban evasion prevention + if clientIP != "" { + cf.pool.Exec(ctx, ` + INSERT INTO banned_ips (ip_address, user_id, reason, banned_at) + VALUES ($1, $2, $3, NOW()) + `, clientIP, userID, fmt.Sprintf("auto-ban: %d strikes in 30 days", count)) + } + fmt.Printf("Content filter: user %s BANNED (%d strikes), IP %s logged\n", userID, count, clientIP) case count >= 5: consequence = "suspend_7d" suspendUntil := time.Now().Add(7 * 24 * time.Hour) diff --git a/go-backend/scripts/create_banned_ips.sql b/go-backend/scripts/create_banned_ips.sql new file mode 100644 index 0000000..b6164d7 --- /dev/null +++ b/go-backend/scripts/create_banned_ips.sql @@ -0,0 +1,11 @@ +-- Banned IPs table for ban evasion prevention +CREATE TABLE IF NOT EXISTS banned_ips ( + id SERIAL PRIMARY KEY, + ip_address TEXT NOT NULL, + user_id UUID REFERENCES users(id), + reason TEXT, + banned_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_banned_ips_address ON banned_ips (ip_address); +CREATE INDEX IF NOT EXISTS idx_banned_ips_user ON banned_ips (user_id);