Unset or empty, TRUSTED_PROXIES now defaults to 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, so a reverse proxy reaching the app over a Docker network or a private LAN gets per-client rate-limit buckets without configuration. A set value replaces the default; an unparseable one still fails startup. The startup warning for an empty list goes, with its test hook and test, since the default is no longer empty. The README's configuration table, Trusted proxies, upaas and reverse-proxy sections describe the new default and when to narrow it to the proxy alone. Model: opus-5-5
312 lines
7.5 KiB
Go
312 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/logfield"
|
|
)
|
|
|
|
// HandleLoginPage returns a handler for the login page (GET)
|
|
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Check if already logged in
|
|
sess, err := h.session.Get(r)
|
|
if err == nil && h.session.IsAuthenticated(sess) {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
|
|
return
|
|
}
|
|
|
|
// Render login page
|
|
data := map[string]any{
|
|
tmplKeyError: "",
|
|
}
|
|
|
|
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,
|
|
)
|
|
|
|
// Redirect to home page
|
|
http.Redirect(w, r, "/", 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,
|
|
}
|
|
|
|
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, 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.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)
|
|
}
|
|
}
|