Files
gochat/backend/internal/middleware/session.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

114 lines
3.8 KiB
Go

package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
applogger "github.com/gochat/gochat/pkg/logger"
)
// SessionMiddlewareConfig holds configuration for the session middleware.
type SessionMiddlewareConfig struct {
SessionHeader string `mapstructure:"session_header"` // default: X-Session-ID
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip session validation
}
// DefaultSessionMiddlewareConfig returns default session middleware configuration.
func DefaultSessionMiddlewareConfig() SessionMiddlewareConfig {
return SessionMiddlewareConfig{
SessionHeader: "X-Session-ID",
SkipPaths: []string{"/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/refresh", "/health"},
}
}
// SessionMiddlewareConfigFromAppConfig derives SessionMiddlewareConfig from the global Config.
func SessionMiddlewareConfigFromAppConfig(cfg *config.Config) SessionMiddlewareConfig {
smc := DefaultSessionMiddlewareConfig()
if cfg.Session.HeaderName != "" {
smc.SessionHeader = cfg.Session.HeaderName
}
smc.SkipPaths = cfg.Session.SkipPaths
return smc
}
// SessionMiddleware validates and loads user sessions from the SessionStore.
// This middleware is complementary to AuthMiddleware:
// - AuthMiddleware validates JWT tokens and sets user_id/role/claims
// - SessionMiddleware validates the session cookie/header and enriches context
//
// Session middleware provides:
// 1. Session continuity across token refreshes
// 2. Force logout capability (delete all sessions for a user)
// 3. Session-scoped metadata (active account switching, UI preferences)
//
// Reference: Chatwoot uses Devise sessions; we replace with JWT + session store.
func SessionMiddleware(store *auth.SessionStore, cfg SessionMiddlewareConfig) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip session validation for configured paths
path := c.Request.URL.Path
for _, skip := range cfg.SkipPaths {
if strings.HasPrefix(path, skip) {
c.Next()
return
}
}
// Look for session ID in header first (API clients), then cookie (web clients)
sessionID := c.GetHeader(cfg.SessionHeader)
if sessionID == "" {
sessionID, _ = c.Cookie("_gochat_session")
}
if sessionID == "" {
// No session provided — this is acceptable for JWT-only auth.
// Session is optional enrichment; JWT remains the primary auth mechanism.
c.Next()
return
}
session, err := store.Get(sessionID)
if err != nil {
// Session not found or expired — clear the stale session cookie
c.SetCookie("_gochat_session", "", -1, "/", "", false, true)
applogger.L().Debugf("Session lookup failed: %v (path=%s)", err, path)
// Don't abort — JWT auth is still valid. Session is enrichment only.
c.Next()
return
}
// Set session data in context for downstream handlers
c.Set("session_id", session.ID)
c.Set("session", session)
// Refresh session expiry (keep-alive on activity)
if refreshed, err := store.Refresh(session.ID); err == nil {
c.Set("session_expires_at", refreshed.ExpiresAt)
// Update session cookie expiry
c.SetCookie("_gochat_session", session.ID,
int(refreshed.ExpiresAt.Sub(refreshed.CreatedAt).Seconds()),
"/", "", true, true)
}
c.Next()
}
}
// RequireSession is a middleware that requires an active session.
// Use on routes where session-based features are mandatory (e.g., WebSocket connections
// that need session continuity, account switching endpoints).
func RequireSession() gin.HandlerFunc {
return func(c *gin.Context) {
sessionID, exists := c.Get("session_id")
if !exists || sessionID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "active session required",
})
return
}
c.Next()
}
}