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)")