From 56a7579c99faf7b5b108bc86be855032b10e515e Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Thu, 13 Nov 2025 10:37:08 +0000
Subject: [PATCH] Add Remember Me feature with sliding session expiration
(Related to #707)
Implements a "Remember Me" option that allows users to stay logged in
for 30 days instead of the default 24 hours. This addresses the pain
point of frequent re-authentication in LAN-only environments while
maintaining authentication security.
Backend changes:
- Add rememberMe field to login request handling
- Support variable session durations (24h default, 30d with Remember Me)
- Implement sliding session expiration that extends sessions on each
authenticated request using the original duration
- Store OriginalDuration in session data for proper sliding window
- Update session cookie MaxAge to match session duration
Frontend changes:
- Add "Remember Me for 30 days" checkbox to login form
- Pass rememberMe flag in login request
- Improve UI with clear duration indication
Key features:
- Sessions extend automatically on each request (sliding window)
- Original duration preserved across session extension
- Backward compatible with existing sessions (legacy sessions work)
- Sessions persist across server restarts
This provides a better user experience for LAN deployments without
compromising security by completely disabling authentication.
---
frontend-modern/src/components/Login.tsx | 26 ++++++++-
internal/api/auth.go | 8 ++-
internal/api/router.go | 20 +++++--
internal/api/session_store.go | 74 +++++++++++++++++-------
4 files changed, 98 insertions(+), 30 deletions(-)
diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx
index 20f994b..e3689b2 100644
--- a/frontend-modern/src/components/Login.tsx
+++ b/frontend-modern/src/components/Login.tsx
@@ -25,6 +25,7 @@ interface SecurityStatus {
export const Login: Component = (props) => {
const [username, setUsername] = createSignal('');
const [password, setPassword] = createSignal('');
+ const [rememberMe, setRememberMe] = createSignal(false);
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [authStatus, setAuthStatus] = createSignal(null);
@@ -183,6 +184,7 @@ export const Login: Component = (props) => {
body: JSON.stringify({
username: username(),
password: password(),
+ rememberMe: rememberMe(),
}),
credentials: 'include', // Important for session cookie
});
@@ -294,6 +296,8 @@ export const Login: Component = (props) => {
setUsername,
password,
setPassword,
+ rememberMe,
+ setRememberMe,
error,
loading,
handleSubmit,
@@ -332,6 +336,8 @@ const LoginForm: Component<{
setUsername: (v: string) => void;
password: () => string;
setPassword: (v: string) => void;
+ rememberMe: () => boolean;
+ setRememberMe: (v: boolean) => void;
error: () => string;
loading: () => boolean;
handleSubmit: (e: Event) => void;
@@ -346,6 +352,8 @@ const LoginForm: Component<{
setUsername,
password,
setPassword,
+ rememberMe,
+ setRememberMe,
error,
loading,
handleSubmit,
@@ -433,7 +441,6 @@ const LoginForm: Component<{
-
+
+ setRememberMe(e.currentTarget.checked)}
+ class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer dark:border-gray-600 dark:bg-gray-700"
+ />
+
+
diff --git a/internal/api/auth.go b/internal/api/auth.go
index cbf5609..85839b2 100644
--- a/internal/api/auth.go
+++ b/internal/api/auth.go
@@ -122,6 +122,11 @@ func ValidateSession(token string) bool {
return GetSessionStore().ValidateSession(token)
}
+// ValidateAndExtendSession validates a session and extends its expiration (sliding window)
+func ValidateAndExtendSession(token string) bool {
+ return GetSessionStore().ValidateAndExtendSession(token)
+}
+
// CheckProxyAuth validates proxy authentication headers
func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
// Check if proxy auth is configured
@@ -287,7 +292,8 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
// Check session cookie (for WebSocket and UI)
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
- if ValidateSession(cookie.Value) {
+ // Use ValidateAndExtendSession for sliding expiration
+ if ValidateAndExtendSession(cookie.Value) {
return true
} else {
// Debug logging for failed session validation
diff --git a/internal/api/router.go b/internal/api/router.go
index 72ba693..988f026 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -2021,8 +2021,9 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
// Parse request
var loginReq struct {
- Username string `json:"username"`
- Password string `json:"password"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ RememberMe bool `json:"rememberMe"`
}
if err := json.NewDecoder(req.Body).Decode(&loginReq); err != nil {
@@ -2087,9 +2088,13 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
return
}
- // Store session persistently
+ // Store session persistently with appropriate duration
userAgent := req.Header.Get("User-Agent")
- GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP)
+ sessionDuration := 24 * time.Hour
+ if loginReq.RememberMe {
+ sessionDuration = 30 * 24 * time.Hour // 30 days
+ }
+ GetSessionStore().CreateSession(token, sessionDuration, userAgent, clientIP)
// Track session for user
TrackUserSession(loginReq.Username, token)
@@ -2100,6 +2105,9 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
// Get appropriate cookie settings based on proxy detection
isSecure, sameSitePolicy := getCookieSettings(req)
+ // Set cookie MaxAge to match session duration
+ cookieMaxAge := int(sessionDuration.Seconds())
+
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "pulse_session",
@@ -2108,7 +2116,7 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
HttpOnly: true,
Secure: isSecure,
SameSite: sameSitePolicy,
- MaxAge: 86400, // 24 hours
+ MaxAge: cookieMaxAge,
})
// Set CSRF cookie (not HttpOnly so JS can read it)
@@ -2118,7 +2126,7 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
Path: "/",
Secure: isSecure,
SameSite: sameSitePolicy,
- MaxAge: 86400, // 24 hours
+ MaxAge: cookieMaxAge,
})
// Audit log successful login
diff --git a/internal/api/session_store.go b/internal/api/session_store.go
index 9007365..f4afd65 100644
--- a/internal/api/session_store.go
+++ b/internal/api/session_store.go
@@ -28,19 +28,21 @@ func sessionHash(token string) string {
}
type sessionPersisted struct {
- Key string `json:"key"`
- ExpiresAt time.Time `json:"expires_at"`
- CreatedAt time.Time `json:"created_at"`
- UserAgent string `json:"user_agent,omitempty"`
- IP string `json:"ip,omitempty"`
+ Key string `json:"key"`
+ ExpiresAt time.Time `json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UserAgent string `json:"user_agent,omitempty"`
+ IP string `json:"ip,omitempty"`
+ OriginalDuration time.Duration `json:"original_duration,omitempty"`
}
// SessionData represents a user session
type SessionData struct {
- ExpiresAt time.Time `json:"expires_at"`
- CreatedAt time.Time `json:"created_at"`
- UserAgent string `json:"user_agent,omitempty"`
- IP string `json:"ip,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UserAgent string `json:"user_agent,omitempty"`
+ IP string `json:"ip,omitempty"`
+ OriginalDuration time.Duration `json:"original_duration,omitempty"` // Track original duration for sliding expiration
}
// NewSessionStore creates a new persistent session store
@@ -91,10 +93,11 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA
key := sessionHash(token)
s.sessions[key] = &SessionData{
- ExpiresAt: time.Now().Add(duration),
- CreatedAt: time.Now(),
- UserAgent: userAgent,
- IP: ip,
+ ExpiresAt: time.Now().Add(duration),
+ CreatedAt: time.Now(),
+ UserAgent: userAgent,
+ IP: ip,
+ OriginalDuration: duration,
}
// Save immediately for important operations
@@ -114,6 +117,31 @@ func (s *SessionStore) ValidateSession(token string) bool {
return time.Now().Before(session.ExpiresAt)
}
+// ValidateAndExtendSession checks if a session is valid and extends it (sliding expiration)
+func (s *SessionStore) ValidateAndExtendSession(token string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ key := sessionHash(token)
+ session, exists := s.sessions[key]
+ if !exists {
+ return false
+ }
+
+ now := time.Now()
+ if now.After(session.ExpiresAt) {
+ return false
+ }
+
+ // Extend session using the original duration (sliding window)
+ if session.OriginalDuration > 0 {
+ session.ExpiresAt = now.Add(session.OriginalDuration)
+ // Note: We don't save immediately for performance, background worker will save periodically
+ }
+
+ return true
+}
+
// ExtendSession extends the expiration of a session
func (s *SessionStore) ExtendSession(token string, duration time.Duration) {
s.mu.Lock()
@@ -182,11 +210,12 @@ func (s *SessionStore) saveUnsafe() {
persisted := make([]sessionPersisted, 0, len(s.sessions))
for key, session := range s.sessions {
persisted = append(persisted, sessionPersisted{
- Key: key,
- ExpiresAt: session.ExpiresAt,
- CreatedAt: session.CreatedAt,
- UserAgent: session.UserAgent,
- IP: session.IP,
+ Key: key,
+ ExpiresAt: session.ExpiresAt,
+ CreatedAt: session.CreatedAt,
+ UserAgent: session.UserAgent,
+ IP: session.IP,
+ OriginalDuration: session.OriginalDuration,
})
}
@@ -234,10 +263,11 @@ func (s *SessionStore) load() {
continue
}
s.sessions[entry.Key] = &SessionData{
- ExpiresAt: entry.ExpiresAt,
- CreatedAt: entry.CreatedAt,
- UserAgent: entry.UserAgent,
- IP: entry.IP,
+ ExpiresAt: entry.ExpiresAt,
+ CreatedAt: entry.CreatedAt,
+ UserAgent: entry.UserAgent,
+ IP: entry.IP,
+ OriginalDuration: entry.OriginalDuration,
}
}
log.Info().Int("loaded", len(s.sessions)).Int("total", len(persisted)).Msg("Sessions loaded from disk (hashed format)")