Show a login notice when a session ends due to idle timeout.

Return a distinct session_idle error from the API and reload to /login with a clear signed-out message.
This commit is contained in:
2026-07-18 20:50:52 +02:00
parent c90a47c3ea
commit e3793f380c
9 changed files with 353 additions and 3 deletions
+6 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"errors"
"net/http"
"strings"
@@ -76,7 +77,11 @@ func (app *App) withMiddleware(next http.Handler) http.Handler {
security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security)
if err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
errorMessage := "authentication required"
if errors.Is(err, auth.ErrSessionIdle) {
errorMessage = "session_idle"
}
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: errorMessage})
return
}
@@ -0,0 +1,83 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestMiddlewareReturnsSessionIdle(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute)
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "session_idle" {
t.Fatalf("expected session_idle, got %q", payload.Error)
}
cleared := false
for _, setCookie := range recorder.Result().Cookies() {
if setCookie.Name == auth.SessionCookieName && setCookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatal("expected session cookie to be cleared")
}
}
func TestMiddlewareReturnsAuthenticationRequiredWithoutCookie(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "authentication required" {
t.Fatalf("expected authentication required, got %q", payload.Error)
}
}
+9 -1
View File
@@ -20,7 +20,10 @@ const (
sessionRotateAfter = time.Hour
)
var ErrSessionNotFound = errors.New("session not found")
var (
ErrSessionNotFound = errors.New("session not found")
ErrSessionIdle = errors.New("session idle")
)
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
type SessionManager struct {
@@ -108,6 +111,7 @@ func (manager *SessionManager) LookupValidSession(
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
var found *settings.SessionRecord
idleMatched := false
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
for index := range store.Sessions {
session := store.Sessions[index]
@@ -116,6 +120,7 @@ func (manager *SessionManager) LookupValidSession(
}
if session.ID == cookie.Value {
if now.Sub(session.LastSeenAt) > idleLimit {
idleMatched = true
continue
}
copySession := session
@@ -129,6 +134,9 @@ func (manager *SessionManager) LookupValidSession(
store.Sessions = remaining
_ = settings.SaveSessions(manager.configDir, store, manager.key)
clearSessionCookie(writer)
if idleMatched {
return settings.SessionRecord{}, ErrSessionIdle
}
return settings.SessionRecord{}, ErrSessionNotFound
}
+91
View File
@@ -0,0 +1,91 @@
package auth
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestLookupValidSessionReturnsIdle(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 5
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security)
if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err)
}
store, err = settings.LoadSessionsOrEmpty(configDir, key)
if err != nil {
t.Fatalf("LoadSessionsOrEmpty: %v", err)
}
if len(store.Sessions) != 0 {
t.Fatalf("expected idle session removed, got %#v", store.Sessions)
}
}
func TestLookupValidSessionMissingCookie(t *testing.T) {
configDir := t.TempDir()
manager := NewSessionManager(configDir, testSessionKey())
security := settings.DefaultSecuritySettings()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
func TestLookupValidSessionUnknownCookie(t *testing.T) {
configDir := t.TempDir()
manager := NewSessionManager(configDir, testSessionKey())
security := settings.DefaultSecuritySettings()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
func testSessionKey() []byte {
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 3)
}
return key
}