From 8b7094b0cb97f8f31b4db5cde7a3e112d1e82a6e Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 3 Aug 2026 00:32:47 +0300 Subject: [PATCH] Open the admin console from serve --open and land login on the intended page --- coordinator/cmd/coordinator/serve_cmd.go | 2 +- .../transport/http/templates/login.html | 1 + .../internal/transport/http/ui_auth.go | 24 +++++++-- .../transport/http/ui_auth_internal_test.go | 53 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/coordinator/cmd/coordinator/serve_cmd.go b/coordinator/cmd/coordinator/serve_cmd.go index fa58f6e..7f0756c 100644 --- a/coordinator/cmd/coordinator/serve_cmd.go +++ b/coordinator/cmd/coordinator/serve_cmd.go @@ -130,7 +130,7 @@ func runServe(args []string) error { AutoMigrate: true, } if *open { - openBrowser("http://" + *addr + "/ui") + openBrowser("http://" + *addr + "/ui/admin") } // Print the login once the server is about to start. diff --git a/coordinator/internal/transport/http/templates/login.html b/coordinator/internal/transport/http/templates/login.html index 9482e91..8e7d22f 100644 --- a/coordinator/internal/transport/http/templates/login.html +++ b/coordinator/internal/transport/http/templates/login.html @@ -13,6 +13,7 @@

SciMesh

Sign in

+ {{if .Next}}{{end}} diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go index 4741126..42f7bc9 100644 --- a/coordinator/internal/transport/http/ui_auth.go +++ b/coordinator/internal/transport/http/ui_auth.go @@ -6,6 +6,8 @@ import ( "encoding/json" "io" "net/http" + "net/url" + "strings" "time" "github.com/emil28092005/SciMesh/coordinator/internal/authctx" @@ -53,7 +55,7 @@ type tokenVerifier interface { } func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) { - s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")}) + s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error"), "Next": r.URL.Query().Get("next")}) } func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) { @@ -85,7 +87,13 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) { return } setSessionCookie(w, r, resp.Token) - http.Redirect(w, r, "/ui", http.StatusSeeOther) + // Land back where the user was headed (e.g. /ui/admin); never follow a + // value that escapes the UI prefix — that would be an open redirect. + next := strings.TrimSpace(r.FormValue("next")) + if next == "" || !strings.HasPrefix(next, "/ui/") { + next = "/ui" + } + http.Redirect(w, r, next, http.StatusSeeOther) } // handleUIRegister creates an account through the userservice, then sends the @@ -167,5 +175,15 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) { } func redirectToLogin(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/login", http.StatusSeeOther) + // Remember where the user was headed so a successful login lands back + // there (e.g. /ui/admin) instead of the control room. + next := r.URL.Path + if !strings.HasPrefix(next, "/ui/") { + next = "" + } + target := "/ui/login" + if next != "" { + target += "?next=" + url.QueryEscape(next) + } + http.Redirect(w, r, target, http.StatusSeeOther) } diff --git a/coordinator/internal/transport/http/ui_auth_internal_test.go b/coordinator/internal/transport/http/ui_auth_internal_test.go index 081ad37..66a129d 100644 --- a/coordinator/internal/transport/http/ui_auth_internal_test.go +++ b/coordinator/internal/transport/http/ui_auth_internal_test.go @@ -173,3 +173,56 @@ func TestHandleUILogoutClearsCookie(t *testing.T) { t.Error("logout must clear the session cookie") } } + +func TestHandleUILoginRedirectsToNext(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"t"}`)) + })) + defer stub.Close() + s := newLoginServer(stub) + + // A UI-scoped next is honoured: the admin lands back on the console. + rec := httptest.NewRecorder() + s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {"/ui/admin"}})) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/admin" { + t.Errorf("got %d -> %q, want 303 -> /ui/admin", rec.Code, rec.Header().Get("Location")) + } + + // Anything outside the UI prefix must not become a redirect target. + for _, next := range []string{"https://evil.example", "/", "//evil.example", "/api/jobs"} { + rec = httptest.NewRecorder() + s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {next}})) + if loc := rec.Header().Get("Location"); loc != "/ui" { + t.Errorf("next=%q landed on %q, want /ui (no open redirect)", next, loc) + } + } +} + +func TestRedirectToLoginCarriesNext(t *testing.T) { + rec := httptest.NewRecorder() + req := newReq(http.MethodGet, "/ui/admin", nil) + redirectToLogin(rec, req) + if loc := rec.Header().Get("Location"); loc != "/ui/login?next=%2Fui%2Fadmin" { + t.Errorf("location = %q, want /ui/login?next=%%2Fui%%2Fadmin", loc) + } + + // Paths outside the UI stay on the plain login. + rec = httptest.NewRecorder() + req = newReq(http.MethodGet, "/health", nil) + redirectToLogin(rec, req) + if loc := rec.Header().Get("Location"); loc != "/ui/login" { + t.Errorf("location = %q, want /ui/login", loc) + } +} + +func TestLoginFormRendersNext(t *testing.T) { + html := render(t, "login.html", map[string]any{"Next": "/ui/admin"}) + if !strings.Contains(html, `name="next" value="/ui/admin"`) { + t.Error("login form must carry the next field") + } + html = render(t, "login.html", map[string]any{}) + if strings.Contains(html, `name="next"`) { + t.Error("login form must not render next when absent") + } +}