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
This commit is contained in:
CNBUGS AI
2026-04-24 16:23:11 +08:00
parent 0ca54fda4a
commit 1a0e743a71
2 changed files with 85 additions and 13 deletions
+58 -7
View File
@@ -8,7 +8,6 @@ import (
"dhcp-dns-manager/internal/dns" "dhcp-dns-manager/internal/dns"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"net/http" "net/http"
"sync"
"time" "time"
) )
@@ -28,6 +27,14 @@ type User struct {
IsAdmin bool 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 { func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Server, cm *ConfigManager) *Server {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -40,6 +47,9 @@ func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Se
configManager: cm, configManager: cm,
} }
// Auto-migrate Session table
s.db.AutoMigrate(&Session{})
// Wire up config reloader so DHCP server picks up web UI config changes // Wire up config reloader so DHCP server picks up web UI config changes
d.SetConfigReloader(func() *config.DHCPConfig { d.SetConfigReloader(func() *config.DHCPConfig {
cfg := cm.GetConfig() cfg := cm.GetConfig()
@@ -49,9 +59,22 @@ func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Se
}) })
s.setupRoutes() s.setupRoutes()
// Start session cleanup goroutine
go s.cleanupSessions()
return s 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() { func (s *Server) setupRoutes() {
// Custom recovery middleware that returns JSON // Custom recovery middleware that returns JSON
s.router.Use(gin.CustomRecovery(func(c *gin.Context, err any) { 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) { s.router.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Server is running"}) c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Server is running"})
}) })
s.router.POST("/api/session/verify", s.handleVerifySession)
// Protected routes // Protected routes
protected := s.router.Group("/api") 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 { func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
sessionID := c.GetHeader("X-Session-ID") sessionID := c.GetHeader("X-Session-ID")
@@ -128,8 +149,9 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
return return
} }
// Validate session exists in store // Validate session from database
if _, ok := sessionStore.Load(sessionID); !ok { 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.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
c.Abort() c.Abort()
return return
@@ -164,7 +186,13 @@ func (s *Server) handleLogin(c *gin.Context) {
// For now, simple demo auth // For now, simple demo auth
if req.Username == "admin" && req.Password == "admin" { if req.Username == "admin" && req.Password == "admin" {
sessionID := fmt.Sprintf("session-%d-%s", time.Now().UnixNano(), req.Username) 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{ c.JSON(http.StatusOK, gin.H{
"session_id": sessionID, "session_id": sessionID,
"is_admin": true, "is_admin": true,
@@ -175,6 +203,29 @@ func (s *Server) handleLogin(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) 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) { func (s *Server) handleDashboard(c *gin.Context) {
leases := s.dhcpServer.GetLeases() leases := s.dhcpServer.GetLeases()
bindings, _ := s.dhcpServer.GetStaticBindings() bindings, _ := s.dhcpServer.GetStaticBindings()
+27 -6
View File
@@ -4,12 +4,33 @@ let autoRefreshEnabled = false;
// Restore session on page load // Restore session on page load
if (sessionId) { if (sessionId) {
document.getElementById('loginSection').style.display = 'none'; // Verify session is still valid
document.getElementById('dashboard').style.display = 'block'; fetch('/api/session/verify', {
document.getElementById('logoutBtn').style.display = 'block'; method: 'POST',
loadDashboard(); headers: { 'Content-Type': 'application/json' },
loadDHCPConfig(); body: JSON.stringify({ session_id: sessionId })
loadDNSConfig(); })
.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 // Auto Refresh