From 18affc1f165f9eb4626e4a9981b0b3fb2bfe3945 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:48:13 +0100 Subject: [PATCH] http: regenerate setup link + force-logout --- internal/server/http/api_users.go | 66 ++++++++++++++++++++++++++++++ internal/server/http/server.go | 2 + internal/server/http/users_test.go | 59 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 00c7c23..48086a5 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -323,3 +323,69 @@ func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Reques }) w.WriteHeader(stdhttp.StatusOK) } + +type regenerateSetupResponse struct { + SetupURL string `json:"setup_url"` +} + +func (s *Server) handleAPIUserRegenerateSetup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + rawToken, err := generateSetupToken() + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + now := time.Now().UTC() + var actorID *string + if actor != nil { + actorID = &actor.ID + } + if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ + UserID: id, TokenHash: hashSetupToken(rawToken), + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, CreatedBy: actorID, + }); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if err := s.deps.Store.SetMustChangePassword(r.Context(), id, true); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.setup_token.regenerated", + TargetKind: ptr("user"), TargetID: &id, TS: now, + }) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(regenerateSetupResponse{ + SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, + }) +} + +func (s *Server) handleAPIUserForceLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + n, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.force_logout", + TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]int64{"sessions_killed": n}) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 5d79eb9..746cf9c 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -257,6 +257,8 @@ func (s *Server) routes(r chi.Router) { r.Patch("/api/users/{id}", s.handleAPIUserPatch) r.Post("/api/users/{id}/disable", s.handleAPIUserDisable) r.Post("/api/users/{id}/enable", s.handleAPIUserEnable) + r.Post("/api/users/{id}/regenerate-setup", s.handleAPIUserRegenerateSetup) + r.Post("/api/users/{id}/force-logout", s.handleAPIUserForceLogout) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index 3f91b2e..e3ddeab 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -7,6 +7,7 @@ import ( stdhttp "net/http" "strings" "testing" + "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -216,3 +217,61 @@ func TestAPIUserDisableRejectsLastAdmin(t *testing.T) { t.Errorf("status: got %d want 409", res.StatusCode) } } + +func TestAPIUserRegenerateSetup(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "newbie", store.RoleViewer) + _ = srv.deps.Store.SetMustChangePassword(t.Context(), target, true) + _ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{ + UserID: target, TokenHash: "old", ExpiresAt: time.Now().UTC().Add(time.Hour), + CreatedAt: time.Now().UTC(), + }) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/regenerate-setup", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + var got struct { + SetupURL string `json:"setup_url"` + } + _ = json.NewDecoder(res.Body).Decode(&got) + if !strings.Contains(got.SetupURL, "/setup?token=") { + t.Errorf("setup_url: %q", got.SetupURL) + } + if _, err := srv.deps.Store.LookupSetupToken(t.Context(), "old"); err == nil { + t.Error("old token should be replaced") + } +} + +func TestAPIUserForceLogout(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "victim", store.RoleOperator) + loginAs(t, srv, target) // create a session for the victim + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/force-logout", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + rr, _ := srv.deps.Store.DeleteSessionsByUserID(t.Context(), target) + if rr != 0 { + t.Errorf("expected 0 remaining sessions, got %d", rr) + } +}