All checks were successful
check / check (push) Successful in 2m54s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503 and no hash runs for it. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
284 lines
6.6 KiB
Go
284 lines
6.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("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", username,
|
|
"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, 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", r.URL.Path,
|
|
)
|
|
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)
|
|
|
|
h.log.Debug("user not found", "username", username)
|
|
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 {
|
|
h.log.Debug("invalid password", "username", username)
|
|
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)
|
|
}
|
|
}
|