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:
parent
8e993ea901
commit
56a7579c99
4 changed files with 98 additions and 30 deletions
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
Loading…
Reference in a new issue