Files
webhooker/internal/handlers/auth.go
T
sneak 30cccebf32
check / check (push) Waiting to run
Logging in returns to the page that was asked for (closes #384)
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
2026-10-01 20:45:57 +00:00

363 lines
9.1 KiB
Go

package handlers
import (
"net/http"
"net/url"
"strconv"
"strings"
"unicode"
"sneak.berlin/go/webhooker/internal/database"
"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)
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
next := loginDestination(
r.URL.Query().Get(middleware.NextParam),
)
// Check if already logged in
sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect( //nolint:gosec // checked by loginDestination
w, r, next, http.StatusSeeOther,
)
return
}
// Render login page
data := map[string]any{
tmplKeyError: "",
tmplKeyNext: next,
}
h.renderTemplate(w, r, "login.html", data)
}
}
// HandleLoginSubmit handles the login form submission (POST)
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// PostFormValue, not FormValue: the credential must come
// from the body, never from the query string.
username := r.PostFormValue("username")
password := r.PostFormValue("password")
// Validate input
if username == "" || password == "" {
h.renderLoginError(
w, r,
"Username and password are required",
http.StatusBadRequest,
)
return
}
user, err := h.authenticateUser(
w, r, username, password,
)
if err != nil {
return
}
err = h.createAuthenticatedSession(w, r, user)
if err != nil {
return
}
h.log.Info(
"user logged in",
"username", logfield.Truncate(
username, logfield.MaxBytes,
),
"user_id", user.ID,
)
// The form value is the client's to set, so it is checked
// 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,
)
}
}
// renderLoginError renders the login page with an error message.
func (h *Handlers) renderLoginError(
w http.ResponseWriter,
r *http.Request,
msg string,
status int,
) {
data := map[string]any{
tmplKeyError: msg,
tmplKeyNext: loginDestination(
r.PostFormValue(middleware.NextParam),
),
}
w.WriteHeader(status)
h.renderTemplate(w, r, "login.html", data)
}
// authenticateUser looks up and verifies a user's credentials.
// On failure it writes an HTTP response and returns an error.
//
// The credential check runs BEFORE any rate-limit budget is
// consulted, and only a failed check spends budget. That is what
// keeps the single administrative path reachable: behind the reverse
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
// client shares one bucket, so a limiter spent on arrival lets any
// stranger deny the operator's own correct password indefinitely.
//
// Verifying first means every login POST costs an Argon2id hash, so
// the work is taken under a bounded number of verification slots.
func (h *Handlers) authenticateUser(
w http.ResponseWriter,
r *http.Request,
username, password string,
) (database.User, error) {
var user database.User
release, ok := h.mw.BeginPasswordVerification(r.Context())
if !ok {
h.log.Warn(
"password verification capacity exhausted",
"path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes,
),
)
h.renderLoginError(
w, r,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
return user, errVerificationBusy
}
defer release()
err := h.db.DB().Where(
"username = ?", username,
).First(&user).Error
if err != nil {
// A username that does not exist is charged the same work
// as one that does. Skipping the hash here would answer in
// microseconds where a real account takes tens of
// milliseconds, handing every client a username oracle.
h.dummyVerifications.Add(1)
database.VerifyDummyPassword(password)
// Login is unauthenticated, and the submitted username is
// a form field the client fills to any length the 1 MB
// body cap allows. On this branch it matched no row, so
// nothing else bounds it. The rate limiter caps how often
// the line is written, not how wide it is.
h.log.Debug(
"user not found",
"username", logfield.Truncate(
username, logfield.MaxBytes,
),
)
h.rejectLogin(w, r, username)
return user, err
}
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err
}
if !valid {
// Reached only once the username matched a stored row, so
// it is bounded by the operator's own data. Capped anyway,
// so that every username this unauthenticated endpoint
// logs is capped and no reader has to work out which
// branch narrowed it.
h.log.Debug(
"invalid password",
"username", logfield.Truncate(
username, logfield.MaxBytes,
),
)
h.rejectLogin(w, r, username)
return user, errInvalidPassword
}
// The password was correct, so forgive whatever failures this
// client accumulated: an operator who mistypes a few times and
// then gets it right must not stay throttled afterwards.
h.mw.ForgiveLoginFailures(r, username)
return user, nil
}
// rejectLogin counts one failed credential verification and answers
// it: 401 while this client still has failure budget against the
// submitted username, 429 with a Retry-After once it is spent.
//
// The 429 throttles wrong passwords only. A correct one never
// reaches here, so no amount of failure — from this client or any
// other sharing its bucket — can keep the operator out.
func (h *Handlers) rejectLogin(
w http.ResponseWriter,
r *http.Request,
username string,
) {
if !h.mw.RecordLoginFailure(r, username) {
h.renderLoginError(
w, r,
"Invalid username or password",
http.StatusUnauthorized,
)
return
}
w.Header().Set("Retry-After", strconv.Itoa(int(
h.mw.LoginFailureInterval().Seconds(),
)))
h.renderLoginError(
w, r,
"Too many failed login attempts. Please try again later.",
http.StatusTooManyRequests,
)
}
// createAuthenticatedSession regenerates the session and stores
// user info. On failure it writes an HTTP response and returns
// an error.
func (h *Handlers) createAuthenticatedSession(
w http.ResponseWriter,
r *http.Request,
user database.User,
) error {
oldSess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
h.session.SetUser(sess, user.ID, user.Username)
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
return nil
}
// HandleLogout handles user logout
func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
// Destroy session
h.session.Destroy(sess)
// Save the destroyed session
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error(
"failed to save destroyed session",
"error", err,
)
}
// Redirect to login page
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
}
}