From 1a0e743a71580741837095b319aaf4514dc3279f Mon Sep 17 00:00:00 2001 From: CNBUGS AI Date: Fri, 24 Apr 2026 16:23:11 +0800 Subject: [PATCH] Implement persistent sessions with 30-day expiration - Move session storage from in-memory to database - Add Session model and auto-migrate table - Set session expiration to 30 days - Add /api/session/verify endpoint for frontend validation - Add background session cleanup task (hourly) - Frontend now verifies session validity on page load - Clear localStorage when session expires --- internal/web/server.go | 65 +++++++++++++++++++++++++++++++++++++----- web/static/js/app.js | 33 +++++++++++++++++---- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 329c1f4..2656c10 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -8,7 +8,6 @@ import ( "dhcp-dns-manager/internal/dns" "github.com/gin-gonic/gin" "net/http" - "sync" "time" ) @@ -28,6 +27,14 @@ type User struct { IsAdmin bool } +// Session represents a user session +type Session struct { + ID string `gorm:"primaryKey"` + UserID string // username + ExpiresAt time.Time + CreatedAt time.Time +} + func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Server, cm *ConfigManager) *Server { gin.SetMode(gin.ReleaseMode) @@ -40,6 +47,9 @@ func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Se configManager: cm, } + // Auto-migrate Session table + s.db.AutoMigrate(&Session{}) + // Wire up config reloader so DHCP server picks up web UI config changes d.SetConfigReloader(func() *config.DHCPConfig { cfg := cm.GetConfig() @@ -49,9 +59,22 @@ func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Se }) s.setupRoutes() + + // Start session cleanup goroutine + go s.cleanupSessions() + return s } +func (s *Server) cleanupSessions() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + s.db.Where("expires_at < ?", time.Now()).Delete(&Session{}) + } +} + func (s *Server) setupRoutes() { // Custom recovery middleware that returns JSON s.router.Use(gin.CustomRecovery(func(c *gin.Context, err any) { @@ -78,6 +101,7 @@ func (s *Server) setupRoutes() { s.router.GET("/api/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Server is running"}) }) + s.router.POST("/api/session/verify", s.handleVerifySession) // Protected routes protected := s.router.Group("/api") @@ -116,9 +140,6 @@ func (s *Server) setupRoutes() { } } -// sessionStore in-memory session store -var sessionStore = sync.Map{} - func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { sessionID := c.GetHeader("X-Session-ID") @@ -128,8 +149,9 @@ func (s *Server) authMiddleware() gin.HandlerFunc { return } - // Validate session exists in store - if _, ok := sessionStore.Load(sessionID); !ok { + // Validate session from database + var session Session + if err := s.db.Where("id = ? AND expires_at > ?", sessionID, time.Now()).First(&session).Error; err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"}) c.Abort() return @@ -164,7 +186,13 @@ func (s *Server) handleLogin(c *gin.Context) { // For now, simple demo auth if req.Username == "admin" && req.Password == "admin" { sessionID := fmt.Sprintf("session-%d-%s", time.Now().UnixNano(), req.Username) - sessionStore.Store(sessionID, true) + session := Session{ + ID: sessionID, + UserID: req.Username, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30 days + } + s.db.Create(&session) + c.JSON(http.StatusOK, gin.H{ "session_id": sessionID, "is_admin": true, @@ -175,6 +203,29 @@ func (s *Server) handleLogin(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) } +func (s *Server) handleVerifySession(c *gin.Context) { + var req struct { + SessionID string `json:"session_id"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var session Session + if err := s.db.Where("id = ? AND expires_at > ?", req.SessionID, time.Now()).First(&session).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "valid": true, + "user_id": session.UserID, + "username": session.UserID, + }) +} + func (s *Server) handleDashboard(c *gin.Context) { leases := s.dhcpServer.GetLeases() bindings, _ := s.dhcpServer.GetStaticBindings() diff --git a/web/static/js/app.js b/web/static/js/app.js index 78f0ea9..a24fbd0 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -4,12 +4,33 @@ let autoRefreshEnabled = false; // Restore session on page load if (sessionId) { - document.getElementById('loginSection').style.display = 'none'; - document.getElementById('dashboard').style.display = 'block'; - document.getElementById('logoutBtn').style.display = 'block'; - loadDashboard(); - loadDHCPConfig(); - loadDNSConfig(); + // Verify session is still valid + fetch('/api/session/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }) + }) + .then(res => res.json()) + .then(data => { + if (data.valid) { + document.getElementById('loginSection').style.display = 'none'; + document.getElementById('dashboard').style.display = 'block'; + document.getElementById('logoutBtn').style.display = 'block'; + loadDashboard(); + loadDHCPConfig(); + loadDNSConfig(); + } else { + // Session expired, clear and show login + localStorage.removeItem('session_id'); + sessionId = null; + } + }) + .catch(err => { + console.error('Session verify error:', err); + // On error, clear and show login + localStorage.removeItem('session_id'); + sessionId = null; + }); } // Auto Refresh