Logging in returns to the page that was asked for (closes #384)
check / check (push) Successful in 5m21s

RequireAuth now sends a logged-out GET to /pages/login with its path
and query in a `next` parameter. The login form carries it as a hidden
field, and a successful login redirects there when it is a path on
this site; anything else, plain or percent-encoded, goes to `/`, which
leads to the webhook list. A browser already logged in that opens the
login page goes to the same place. The navigation bar on the login
page no longer links to the login page.

Model: opus-5-5
This commit is contained in:
2026-10-01 21:45:40 +00:00
parent a56f1fe0c8
commit 3ee7acbe32
10 changed files with 321 additions and 15 deletions
+21 -2
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"sync"
"time"
@@ -366,6 +367,24 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
}
}
// NextParam is the query parameter on the login redirect, and the
// login form field, that holds the page to return to after login.
const NextParam = "next"
// loginURL is the login page RequireAuth redirects to. A GET carries
// its own path and query in NextParam so that logging in returns to
// it; HandleLoginSubmit decides whether that value is safe to follow.
// Other methods carry nothing, since a redirect cannot repeat them.
func loginURL(r *http.Request) string {
if r.Method != http.MethodGet {
return "/pages/login"
}
return "/pages/login?" + url.Values{
NextParam: {r.URL.RequestURI()},
}.Encode()
}
// RequireAuth returns middleware that checks for a valid session.
// Unauthenticated users are redirected to the login page.
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
@@ -381,7 +400,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
"error", err,
)
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
w, r, loginURL(r), http.StatusSeeOther,
)
return
@@ -409,7 +428,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
),
)
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
w, r, loginURL(r), http.StatusSeeOther,
)
return
+42 -2
View File
@@ -338,6 +338,42 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
"unauthenticated request",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
}
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
// redirect carries: a GET's path and query, so logging in can return
// there, and nothing for a POST, which a redirect cannot repeat.
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
get := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/source/abc/logs?page=2", nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(
t, "/pages/login?next=%2Fsource%2Fabc%2Flogs%3Fpage%3D2",
w.Header().Get("Location"),
)
post := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/source/abc/delete", nil,
)
w = httptest.NewRecorder()
handler.ServeHTTP(w, post)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
@@ -443,7 +479,9 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
"unauthenticated session",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
}
// --- RequireAuth Session Expiry Tests ---
@@ -541,7 +579,9 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
"handler should not run for an idle-expired session",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
assert.Empty(
t, sessionCookies(w),
"an expired session must not be refreshed",