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) => {
|
export const Login: Component<LoginProps> = (props) => {
|
||||||
const [username, setUsername] = createSignal('');
|
const [username, setUsername] = createSignal('');
|
||||||
const [password, setPassword] = createSignal('');
|
const [password, setPassword] = createSignal('');
|
||||||
|
const [rememberMe, setRememberMe] = createSignal(false);
|
||||||
const [error, setError] = createSignal('');
|
const [error, setError] = createSignal('');
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [authStatus, setAuthStatus] = createSignal<SecurityStatus | null>(null);
|
const [authStatus, setAuthStatus] = createSignal<SecurityStatus | null>(null);
|
||||||
|
|
@ -183,6 +184,7 @@ export const Login: Component<LoginProps> = (props) => {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username(),
|
username: username(),
|
||||||
password: password(),
|
password: password(),
|
||||||
|
rememberMe: rememberMe(),
|
||||||
}),
|
}),
|
||||||
credentials: 'include', // Important for session cookie
|
credentials: 'include', // Important for session cookie
|
||||||
});
|
});
|
||||||
|
|
@ -294,6 +296,8 @@ export const Login: Component<LoginProps> = (props) => {
|
||||||
setUsername,
|
setUsername,
|
||||||
password,
|
password,
|
||||||
setPassword,
|
setPassword,
|
||||||
|
rememberMe,
|
||||||
|
setRememberMe,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|
@ -332,6 +336,8 @@ const LoginForm: Component<{
|
||||||
setUsername: (v: string) => void;
|
setUsername: (v: string) => void;
|
||||||
password: () => string;
|
password: () => string;
|
||||||
setPassword: (v: string) => void;
|
setPassword: (v: string) => void;
|
||||||
|
rememberMe: () => boolean;
|
||||||
|
setRememberMe: (v: boolean) => void;
|
||||||
error: () => string;
|
error: () => string;
|
||||||
loading: () => boolean;
|
loading: () => boolean;
|
||||||
handleSubmit: (e: Event) => void;
|
handleSubmit: (e: Event) => void;
|
||||||
|
|
@ -346,6 +352,8 @@ const LoginForm: Component<{
|
||||||
setUsername,
|
setUsername,
|
||||||
password,
|
password,
|
||||||
setPassword,
|
setPassword,
|
||||||
|
rememberMe,
|
||||||
|
setRememberMe,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|
@ -433,7 +441,6 @@ const LoginForm: Component<{
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<input type="hidden" name="remember" value="true" />
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label for="username" class="sr-only">
|
<label for="username" class="sr-only">
|
||||||
|
|
@ -497,6 +504,23 @@ const LoginForm: Component<{
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,11 @@ func ValidateSession(token string) bool {
|
||||||
return GetSessionStore().ValidateSession(token)
|
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
|
// CheckProxyAuth validates proxy authentication headers
|
||||||
func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
|
func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
|
||||||
// Check if proxy auth is configured
|
// 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)
|
// Check session cookie (for WebSocket and UI)
|
||||||
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
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
|
return true
|
||||||
} else {
|
} else {
|
||||||
// Debug logging for failed session validation
|
// Debug logging for failed session validation
|
||||||
|
|
|
||||||
|
|
@ -2021,8 +2021,9 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||||
|
|
||||||
// Parse request
|
// Parse request
|
||||||
var loginReq struct {
|
var loginReq struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
RememberMe bool `json:"rememberMe"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(req.Body).Decode(&loginReq); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store session persistently
|
// Store session persistently with appropriate duration
|
||||||
userAgent := req.Header.Get("User-Agent")
|
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
|
// Track session for user
|
||||||
TrackUserSession(loginReq.Username, token)
|
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
|
// Get appropriate cookie settings based on proxy detection
|
||||||
isSecure, sameSitePolicy := getCookieSettings(req)
|
isSecure, sameSitePolicy := getCookieSettings(req)
|
||||||
|
|
||||||
|
// Set cookie MaxAge to match session duration
|
||||||
|
cookieMaxAge := int(sessionDuration.Seconds())
|
||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "pulse_session",
|
Name: "pulse_session",
|
||||||
|
|
@ -2108,7 +2116,7 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: isSecure,
|
Secure: isSecure,
|
||||||
SameSite: sameSitePolicy,
|
SameSite: sameSitePolicy,
|
||||||
MaxAge: 86400, // 24 hours
|
MaxAge: cookieMaxAge,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set CSRF cookie (not HttpOnly so JS can read it)
|
// 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: "/",
|
Path: "/",
|
||||||
Secure: isSecure,
|
Secure: isSecure,
|
||||||
SameSite: sameSitePolicy,
|
SameSite: sameSitePolicy,
|
||||||
MaxAge: 86400, // 24 hours
|
MaxAge: cookieMaxAge,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Audit log successful login
|
// Audit log successful login
|
||||||
|
|
|
||||||
|
|
@ -28,19 +28,21 @@ func sessionHash(token string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionPersisted struct {
|
type sessionPersisted struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
|
OriginalDuration time.Duration `json:"original_duration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionData represents a user session
|
// SessionData represents a user session
|
||||||
type SessionData struct {
|
type SessionData struct {
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
IP string `json:"ip,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
|
// NewSessionStore creates a new persistent session store
|
||||||
|
|
@ -91,10 +93,11 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA
|
||||||
|
|
||||||
key := sessionHash(token)
|
key := sessionHash(token)
|
||||||
s.sessions[key] = &SessionData{
|
s.sessions[key] = &SessionData{
|
||||||
ExpiresAt: time.Now().Add(duration),
|
ExpiresAt: time.Now().Add(duration),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
IP: ip,
|
IP: ip,
|
||||||
|
OriginalDuration: duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save immediately for important operations
|
// Save immediately for important operations
|
||||||
|
|
@ -114,6 +117,31 @@ func (s *SessionStore) ValidateSession(token string) bool {
|
||||||
return time.Now().Before(session.ExpiresAt)
|
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
|
// ExtendSession extends the expiration of a session
|
||||||
func (s *SessionStore) ExtendSession(token string, duration time.Duration) {
|
func (s *SessionStore) ExtendSession(token string, duration time.Duration) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
@ -182,11 +210,12 @@ func (s *SessionStore) saveUnsafe() {
|
||||||
persisted := make([]sessionPersisted, 0, len(s.sessions))
|
persisted := make([]sessionPersisted, 0, len(s.sessions))
|
||||||
for key, session := range s.sessions {
|
for key, session := range s.sessions {
|
||||||
persisted = append(persisted, sessionPersisted{
|
persisted = append(persisted, sessionPersisted{
|
||||||
Key: key,
|
Key: key,
|
||||||
ExpiresAt: session.ExpiresAt,
|
ExpiresAt: session.ExpiresAt,
|
||||||
CreatedAt: session.CreatedAt,
|
CreatedAt: session.CreatedAt,
|
||||||
UserAgent: session.UserAgent,
|
UserAgent: session.UserAgent,
|
||||||
IP: session.IP,
|
IP: session.IP,
|
||||||
|
OriginalDuration: session.OriginalDuration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,10 +263,11 @@ func (s *SessionStore) load() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.sessions[entry.Key] = &SessionData{
|
s.sessions[entry.Key] = &SessionData{
|
||||||
ExpiresAt: entry.ExpiresAt,
|
ExpiresAt: entry.ExpiresAt,
|
||||||
CreatedAt: entry.CreatedAt,
|
CreatedAt: entry.CreatedAt,
|
||||||
UserAgent: entry.UserAgent,
|
UserAgent: entry.UserAgent,
|
||||||
IP: entry.IP,
|
IP: entry.IP,
|
||||||
|
OriginalDuration: entry.OriginalDuration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Info().Int("loaded", len(s.sessions)).Int("total", len(persisted)).Msg("Sessions loaded from disk (hashed format)")
|
log.Info().Int("loaded", len(s.sessions)).Int("total", len(persisted)).Msg("Sessions loaded from disk (hashed format)")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue