check / check (push) Successful in 3m21s
The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged. Model: opus-5-5
341 行
8.5 KiB
Go
341 行
8.5 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"
|
|
)
|
|
|
|
// 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) > middleware.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)
|
|
h.renderError(w, r, 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),
|
|
),
|
|
}
|
|
|
|
h.renderTemplateStatus(w, r, "login.html", data, status)
|
|
}
|
|
|
|
// 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, when TRUSTED_PROXIES does not cover
|
|
// it, 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.serverError(w, r, "failed to verify password", err)
|
|
|
|
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 sign-in 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.serverError(w, r, "failed to get session", err)
|
|
|
|
return err
|
|
}
|
|
|
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
|
if err != nil {
|
|
h.serverError(w, r, "failed to regenerate session", err)
|
|
|
|
return err
|
|
}
|
|
|
|
h.session.SetUser(sess, user.ID, user.Username)
|
|
|
|
err = h.session.Save(r, w, sess)
|
|
if err != nil {
|
|
h.serverError(w, r, "failed to save session", err)
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, withNotice("/pages/login", signedOut),
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|