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, 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) } }