diff --git a/service/internal/api/middleware.go b/service/internal/api/middleware.go index ef6f7f8..853b47e 100644 --- a/service/internal/api/middleware.go +++ b/service/internal/api/middleware.go @@ -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 } diff --git a/service/internal/api/middleware_idle_test.go b/service/internal/api/middleware_idle_test.go new file mode 100644 index 0000000..77f66a8 --- /dev/null +++ b/service/internal/api/middleware_idle_test.go @@ -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) + } +} diff --git a/service/internal/auth/session.go b/service/internal/auth/session.go index bb646db..ac1e9b9 100644 --- a/service/internal/auth/session.go +++ b/service/internal/auth/session.go @@ -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 } diff --git a/service/internal/auth/session_test.go b/service/internal/auth/session_test.go new file mode 100644 index 0000000..56ad29e --- /dev/null +++ b/service/internal/auth/session_test.go @@ -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 +} diff --git a/webui/src/App.css b/webui/src/App.css index eeda4ff..b91abe1 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -1065,6 +1065,12 @@ font-size: 0.9rem; } +.wizard-notice { + margin: 0 0 0.85rem; + color: var(--ctp-subtext0); + font-size: 0.9rem; +} + .wizard-rules { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize: vertical; diff --git a/webui/src/LoginPage.test.tsx b/webui/src/LoginPage.test.tsx new file mode 100644 index 0000000..eb694ae --- /dev/null +++ b/webui/src/LoginPage.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LoginPage } from './LoginPage' + +describe('LoginPage', () => { + afterEach(() => { + window.history.replaceState(null, '', '/login') + vi.restoreAllMocks() + }) + + it('shows an idle signed-out notice from the query string', () => { + window.history.replaceState(null, '', '/login?reason=idle') + + render() + + expect( + screen.getByText( + /You have been signed out because your idle time limit was reached/i, + ), + ).toBeInTheDocument() + expect(window.location.search).toBe('') + }) + + it('does not show an idle notice without the query reason', () => { + window.history.replaceState(null, '', '/login') + + render() + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) +}) diff --git a/webui/src/LoginPage.tsx b/webui/src/LoginPage.tsx index b6d5bb2..45d3f20 100644 --- a/webui/src/LoginPage.tsx +++ b/webui/src/LoginPage.tsx @@ -1,15 +1,34 @@ import { useState, type FormEvent } from 'react' import { login } from './api/client' +const IDLE_SIGNED_OUT_MESSAGE = + 'You have been signed out because your idle time limit was reached.' + type LoginPageProps = { onLoggedIn: (username: string) => void } +function readIdleSignedOutMessage(): string | null { + if (typeof window === 'undefined') { + return null + } + const params = new URLSearchParams(window.location.search) + if (params.get('reason') !== 'idle') { + return null + } + params.delete('reason') + const nextSearch = params.toString() + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}` + window.history.replaceState(null, '', nextUrl) + return IDLE_SIGNED_OUT_MESSAGE +} + export function LoginPage({ onLoggedIn }: LoginPageProps) { const [username, setUsername] = useState('Admin') const [password, setPassword] = useState('') const [totpCode, setTotpCode] = useState('') const [errorMessage, setErrorMessage] = useState(null) + const [idleMessage] = useState(() => readIdleSignedOutMessage()) const [isSubmitting, setIsSubmitting] = useState(false) async function handleSubmit(event: FormEvent) { @@ -35,6 +54,12 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) { Use the administrator account created during setup.

+ {idleMessage ? ( +

+ {idleMessage} +

+ ) : null} + {errorMessage ? (

{errorMessage} diff --git a/webui/src/api/client.test.ts b/webui/src/api/client.test.ts index ada422b..9c3653b 100644 --- a/webui/src/api/client.test.ts +++ b/webui/src/api/client.test.ts @@ -14,6 +14,7 @@ import { fetchStatus, fetchUsers, getApiBaseUrl, + login, patchAction, saveNetwork, saveSecurity, @@ -716,3 +717,71 @@ describe('actions API', () => { ) }) }) + +describe('session idle redirect', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reloads to login when an authenticated call returns session_idle', async () => { + const assign = vi.fn() + vi.stubGlobal('location', { + pathname: '/configuration/security', + assign, + }) + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + clone: () => ({ + json: async () => ({ error: 'session_idle' }), + }), + json: async () => ({ error: 'session_idle' }), + }) + + await expect(fetchStatus(fetchMock)).rejects.toThrow() + expect(assign).toHaveBeenCalledWith('/login?reason=idle') + }) + + it('does not redirect on login 401', async () => { + const assign = vi.fn() + vi.stubGlobal('location', { + pathname: '/login', + assign, + }) + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + clone: () => ({ + json: async () => ({ error: 'invalid credentials' }), + }), + json: async () => ({ error: 'invalid credentials' }), + }) + + await expect(login('Admin', 'wrong', '', fetchMock)).rejects.toThrow( + 'invalid credentials', + ) + expect(assign).not.toHaveBeenCalled() + }) + + it('does not redirect for generic authentication required', async () => { + const assign = vi.fn() + vi.stubGlobal('location', { + pathname: '/', + assign, + }) + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + clone: () => ({ + json: async () => ({ error: 'authentication required' }), + }), + json: async () => ({ error: 'authentication required' }), + }) + + await expect(fetchStatus(fetchMock)).rejects.toThrow() + expect(assign).not.toHaveBeenCalled() + }) +}) diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index adc4910..9896776 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -154,6 +154,36 @@ async function readErrorMessage(response: Response): Promise { return `request failed: ${response.status}` } +const SESSION_IDLE_ERROR = 'session_idle' +const IDLE_LOGIN_PATH = '/login?reason=idle' + +function shouldRedirectOnSessionIdle(path: string): boolean { + if (path === '/api/v1/auth/login') { + return false + } + if (typeof window === 'undefined') { + return false + } + return window.location.pathname !== '/login' +} + +async function redirectIfSessionIdle( + response: Response, + path: string, +): Promise { + if (response.status !== 401 || !shouldRedirectOnSessionIdle(path)) { + return + } + try { + const payload = (await response.clone().json()) as { error?: string } + if (payload.error === SESSION_IDLE_ERROR) { + window.location.assign(IDLE_LOGIN_PATH) + } + } catch { + // ignore non-JSON bodies + } +} + async function apiFetch( path: string, init: RequestInit = {}, @@ -163,11 +193,13 @@ async function apiFetch( if (init.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } - return fetchImpl(`${getApiBaseUrl()}${path}`, { + const response = await fetchImpl(`${getApiBaseUrl()}${path}`, { ...init, headers, credentials: 'include', }) + await redirectIfSessionIdle(response, path) + return response } export async function fetchHealth(