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.
This commit is contained in:
rcourtman 2025-11-13 10:37:08 +00:00
parent 8e993ea901
commit 56a7579c99
4 changed files with 98 additions and 30 deletions

View file

@ -25,6 +25,7 @@ interface SecurityStatus {
export const Login: Component<LoginProps> = (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<SecurityStatus | null>(null);
@ -183,6 +184,7 @@ export const Login: Component<LoginProps> = (props) => {
body: JSON.stringify({
username: username(),
password: password(),
rememberMe: rememberMe(),
}),
credentials: 'include', // Important for session cookie
});
@ -294,6 +296,8 @@ export const Login: Component<LoginProps> = (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<{
</p>
</div>
</Show>
<input type="hidden" name="remember" value="true" />
<div class="space-y-4">
<div class="relative">
<label for="username" class="sr-only">
@ -497,6 +504,23 @@ const LoginForm: Component<{
onInput={(e) => setPassword(e.currentTarget.value)}
/>
</div>
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
checked={rememberMe()}
onChange={(e) => 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"
/>
<label
for="remember-me"
class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
onClick={() => setRememberMe(!rememberMe())}
>
Remember me for 30 days
</label>
</div>
</div>
<Show when={error()}>

View file

@ -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

View file

@ -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

View file

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