From 56108ffc33285ce8298b25b8375a5c9842e2be21 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:22:07 +0100 Subject: [PATCH] http: session/login reject disabled users; mid-session disable kicks immediately --- internal/server/http/auth.go | 3 ++ internal/server/http/jobs.go | 6 ++++ internal/server/http/rbac_test.go | 47 +++++++++++++++++++++++++++++ internal/server/http/ui_handlers.go | 4 +++ 4 files changed, 60 insertions(+) diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 6c0fc2e..508c6b4 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -59,6 +59,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req if err := auth.VerifyPassword(u.PasswordHash, password); err != nil { return nil, errInvalidCredentials } + if u.DisabledAt != nil { + return nil, errInvalidCredentials + } token, err := auth.NewToken() if err != nil { diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index 592c64e..7582307 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -152,6 +152,12 @@ func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) { if err != nil { return nil, false } + if u.DisabledAt != nil { + // Disabled mid-session — kill the session and reject the + // request as if it were unauthenticated. + _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) + return nil, false + } return u, true } diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index 2e147a8..42f6b44 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -1,10 +1,13 @@ package http import ( + "bytes" + "encoding/json" stdhttp "net/http" "net/http/httptest" "strings" "testing" + "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -95,6 +98,50 @@ func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) { } } +func TestRequireRoleRejectsDisabledMidSession(t *testing.T) { + t.Parallel() + srv, urlBase := newTestServer(t, false) + uid := makeUser(t, srv, "victim", store.RoleOperator) + cookie := loginAs(t, srv, uid) + + // Disable the user *while their session is still valid*. + if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil { + t.Fatalf("disable: %v", err) + } + + req, _ := stdhttp.NewRequest("GET", urlBase+"/api/hosts", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", res.StatusCode) + } +} + +func TestLoginRejectsDisabledUser(t *testing.T) { + t.Parallel() + srv, urlBase := newTestServer(t, false) + uid := makeUser(t, srv, "disabled1", store.RoleOperator) + if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil { + t.Fatalf("disable: %v", err) + } + + body, _ := json.Marshal(map[string]string{ + "username": "disabled1", "password": "test-password", + }) + res, err := stdhttp.Post(urlBase+"/api/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", res.StatusCode) + } +} + func TestAdminBandRejectsOperator(t *testing.T) { t.Parallel() // This test will start asserting 403 once Task B4 mounts /api/users diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 8bf9f8c..8e9370f 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -66,6 +66,10 @@ func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { } return nil, err } + if u.DisabledAt != nil { + _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) + return nil, nil + } return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil }