Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee7acbe32 | ||
|
|
a56f1fe0c8 |
@@ -2692,12 +2692,16 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------- | ----------- |
|
| ------ | --------------- | ----------- |
|
||||||
| `GET` | `/pages/login` | Login page (not rate limited) |
|
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` |
|
||||||
| `POST` | `/pages/login` | Login form submission. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
||||||
| `POST` | `/pages/logout` | Logout (destroys session) |
|
| `POST` | `/pages/logout` | Logout (destroys session) |
|
||||||
|
|
||||||
#### Authenticated Endpoints
|
#### Authenticated Endpoints
|
||||||
|
|
||||||
|
A logged-out `GET` of any of these is redirected to `/pages/login` with
|
||||||
|
its path and query as `next`, so logging in returns to the page that was
|
||||||
|
asked for.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
|
|||||||
@@ -2,19 +2,62 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxNextBytes bounds the page to return to after login. The login
|
||||||
|
// page writes it into its form, and every page is rendered into a
|
||||||
|
// buffer first (see executeTemplate), so without a bound a request
|
||||||
|
// would choose the size of that buffer.
|
||||||
|
const maxNextBytes = 2048
|
||||||
|
|
||||||
|
// loginDestination returns where a successful login sends the
|
||||||
|
// browser: next when it is a path on this site, otherwise "/", which
|
||||||
|
// leads to the webhook list.
|
||||||
|
//
|
||||||
|
// A browser reads "//host" as another site, reads "\" as "/", and
|
||||||
|
// drops tabs and newlines before reading at all. So the value must
|
||||||
|
// start with exactly one "/" and hold no "\" or control character
|
||||||
|
// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It
|
||||||
|
// is checked after percent-decoding, so an encoded form of any of
|
||||||
|
// these is refused too.
|
||||||
|
func loginDestination(next string) string {
|
||||||
|
if len(next) > maxNextBytes {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := url.PathUnescape(next)
|
||||||
|
if err != nil ||
|
||||||
|
!strings.HasPrefix(decoded, "/") ||
|
||||||
|
strings.HasPrefix(decoded, "//") ||
|
||||||
|
strings.Contains(decoded, `\`) ||
|
||||||
|
strings.ContainsFunc(decoded, unicode.IsControl) {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
// HandleLoginPage returns a handler for the login page (GET)
|
// HandleLoginPage returns a handler for the login page (GET)
|
||||||
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := loginDestination(
|
||||||
|
r.URL.Query().Get(middleware.NextParam),
|
||||||
|
)
|
||||||
|
|
||||||
// Check if already logged in
|
// Check if already logged in
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err == nil && h.session.IsAuthenticated(sess) {
|
if err == nil && h.session.IsAuthenticated(sess) {
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect( //nolint:gosec // checked by loginDestination
|
||||||
|
w, r, next, http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -22,6 +65,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|||||||
// Render login page
|
// Render login page
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyError: "",
|
tmplKeyError: "",
|
||||||
|
tmplKeyNext: next,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "login.html", data)
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
@@ -77,8 +121,13 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
"user_id", user.ID,
|
"user_id", user.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redirect to home page
|
// The form value is the client's to set, so it is checked
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
// again here rather than trusted from the rendered page.
|
||||||
|
http.Redirect( //nolint:gosec // checked by loginDestination
|
||||||
|
w, r,
|
||||||
|
loginDestination(r.PostFormValue(middleware.NextParam)),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +140,9 @@ func (h *Handlers) renderLoginError(
|
|||||||
) {
|
) {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyError: msg,
|
tmplKeyError: msg,
|
||||||
|
tmplKeyNext: loginDestination(
|
||||||
|
r.PostFormValue(middleware.NextParam),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -454,6 +454,151 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login
|
||||||
|
// returns to is client-chosen, so anything that is not a path on this
|
||||||
|
// site, plain or percent-encoded, must land on "/", the webhook list.
|
||||||
|
func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
seedOperator(t, db)
|
||||||
|
|
||||||
|
cases := []struct{ next, want string }{
|
||||||
|
{"/source/abc/logs?page=2", "/source/abc/logs?page=2"},
|
||||||
|
{"", "/"},
|
||||||
|
{"https://evil.example/", "/"},
|
||||||
|
{"https%3A%2F%2Fevil.example%2F", "/"},
|
||||||
|
{"//evil.example/", "/"},
|
||||||
|
{"%2F%2Fevil.example/", "/"},
|
||||||
|
{"/%2Fevil.example/", "/"},
|
||||||
|
{`/\evil.example/`, "/"},
|
||||||
|
{"%2F%5Cevil.example/", "/"},
|
||||||
|
{"/%5Cevil.example/", "/"},
|
||||||
|
{`/a/../\evil.example/`, "/"},
|
||||||
|
{"/\t/evil.example/", "/"},
|
||||||
|
{"/%09/evil.example/", "/"},
|
||||||
|
{"/" + strings.Repeat("a", 4096), "/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("username", operatorUser)
|
||||||
|
form.Set("password", operatorPassword)
|
||||||
|
form.Set("next", c.next)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost,
|
||||||
|
"/pages/login",
|
||||||
|
strings.NewReader(form.Encode()),
|
||||||
|
)
|
||||||
|
req.Header.Set(
|
||||||
|
"Content-Type", "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
req.RemoteAddr = sharedProxyPeer
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleLoginSubmit().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
|
||||||
|
assert.Equal(
|
||||||
|
t, c.want, w.Header().Get("Location"), "next %q", c.next,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginPageGet renders the login page as a GET with the given next
|
||||||
|
// value and cookies.
|
||||||
|
func loginPageGet(
|
||||||
|
h *handlers.Handlers, next string, cookies []*http.Cookie,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet,
|
||||||
|
"/pages/login?"+url.Values{"next": {next}}.Encode(), nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleLoginPage().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page
|
||||||
|
// itself: its form carries the requested page only when it is a path
|
||||||
|
// on this site, and a browser already logged in goes straight there,
|
||||||
|
// or to "/" when it is not.
|
||||||
|
func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, loginPageGet(h, "/source/abc", nil).Body.String(),
|
||||||
|
`name="next" value="/source/abc"`,
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, loginPageGet(h, "//evil.example/", nil).Body.String(),
|
||||||
|
`name="next" value="/"`,
|
||||||
|
)
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
|
||||||
|
|
||||||
|
cases := []struct{ next, want string }{
|
||||||
|
{"/source/abc", "/source/abc"},
|
||||||
|
{"//evil.example/", "/"},
|
||||||
|
{`/\evil.example/`, "/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
w := loginPageGet(h, c.next, cookies)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
|
||||||
|
assert.Equal(
|
||||||
|
t, c.want, w.Header().Get("Location"), "next %q", c.next,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginPage_HasNoLinkToItself: the navigation bar on the login
|
||||||
|
// page offers no link to the login page.
|
||||||
|
func TestLoginPage_HasNoLinkToItself(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &h)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
w := loginPageGet(h, "", nil)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), `href="/pages/login"`)
|
||||||
|
}
|
||||||
|
|
||||||
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
||||||
// database.MaxUsernameBytes still fits in the session cookie. Past
|
// database.MaxUsernameBytes still fits in the session cookie. Past
|
||||||
// what the cookie can carry, a correct login answers 500.
|
// what the cookie can carry, a correct login answers 500.
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const (
|
|||||||
tmplKeyError = "Error"
|
tmplKeyError = "Error"
|
||||||
// tmplKeyWebhook is the template data key for a webhook.
|
// tmplKeyWebhook is the template data key for a webhook.
|
||||||
tmplKeyWebhook = "Webhook"
|
tmplKeyWebhook = "Webhook"
|
||||||
|
// tmplKeyNext is the template data key for the page to return
|
||||||
|
// to after login.
|
||||||
|
tmplKeyNext = "Next"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errInvalidPassword is returned when a password does not match.
|
// errInvalidPassword is returned when a password does not match.
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
|
|||||||
h.HandleProfile().ServeHTTP(w, req)
|
h.HandleProfile().ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Account Information")
|
||||||
|
assert.NotContains(t, w.Body.String(), "Account Type")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
||||||
@@ -158,7 +160,10 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
"handler must not be reached for unauthenticated request",
|
"handler must not be reached for unauthenticated request",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
assert.Equal(
|
||||||
|
t, "/pages/login?next=%2Fuser%2Ftestuser",
|
||||||
|
w.Header().Get("Location"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// passwordChangeRequest builds a POST request to the password-change
|
// passwordChangeRequest builds a POST request to the password-change
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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.
|
// RequireAuth returns middleware that checks for a valid session.
|
||||||
// Unauthenticated users are redirected to the login page.
|
// Unauthenticated users are redirected to the login page.
|
||||||
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||||
@@ -381,7 +400,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/pages/login", http.StatusSeeOther,
|
w, r, loginURL(r), http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -409,7 +428,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/pages/login", http.StatusSeeOther,
|
w, r, loginURL(r), http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -338,6 +338,42 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
|||||||
"unauthenticated request",
|
"unauthenticated request",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
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"))
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +479,9 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
|||||||
"unauthenticated session",
|
"unauthenticated session",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
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 ---
|
// --- RequireAuth Session Expiry Tests ---
|
||||||
@@ -541,7 +579,9 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
|||||||
"handler should not run for an idle-expired session",
|
"handler should not run for an idle-expired session",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
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(
|
assert.Empty(
|
||||||
t, sessionCookies(w),
|
t, sessionCookies(w),
|
||||||
"an expired session must not be refreshed",
|
"an expired session must not be refreshed",
|
||||||
|
|||||||
@@ -680,6 +680,44 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPagesLogin_ReturnsToTheRequestedPage is
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/384: a page opened while
|
||||||
|
// logged out leads to the login page, and logging in from there lands
|
||||||
|
// on that page, query included.
|
||||||
|
func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
username = "operator"
|
||||||
|
password = "correct-horse-battery-staple"
|
||||||
|
)
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
userID, _ := env.seedUser(t, username, password)
|
||||||
|
asked := "/source/" + env.seedWebhook(t, userID).ID + "/logs?page=2"
|
||||||
|
|
||||||
|
bounced := env.get(asked, nil)
|
||||||
|
require.Equal(t, http.StatusSeeOther, bounced.Code)
|
||||||
|
|
||||||
|
loginPage := bounced.Header().Get("Location")
|
||||||
|
|
||||||
|
match := regexp.MustCompile(`name="next" value="([^"]*)"`).
|
||||||
|
FindStringSubmatch(env.get(loginPage, nil).Body.String())
|
||||||
|
require.Len(t, match, 2, "the login form must carry the page")
|
||||||
|
|
||||||
|
token, cookies := env.csrfFrom(t, loginPage, nil)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
form.Set("username", username)
|
||||||
|
form.Set("password", password)
|
||||||
|
form.Set("next", html.UnescapeString(match[1]))
|
||||||
|
|
||||||
|
w := env.post("/pages/login", form, cookies)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(t, asked, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
@@ -830,7 +868,10 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|||||||
|
|
||||||
anon := env.get(path, nil)
|
anon := env.get(path, nil)
|
||||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
||||||
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
assert.Equal(
|
||||||
|
t, "/pages/login?next="+url.QueryEscape(path),
|
||||||
|
anon.Header().Get("Location"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
|
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
<form method="POST" action="/pages/login" class="space-y-6">
|
<form method="POST" action="/pages/login" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<input type="hidden" name="next" value="{{.Next}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username" class="label">Username</label>
|
<label for="username" class="label">Username</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
|
{{if .User}}
|
||||||
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
@@ -28,8 +30,6 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
|
||||||
<a href="/pages/login" class="btn-primary">Login</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,8 +44,6 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
|
||||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,10 +41,6 @@
|
|||||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
|
||||||
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
|
||||||
<dd class="text-sm text-gray-900">Standard User</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user