Files
felhom-controller/controller/internal/web/auth.go
T
admin de39e47f53 R-241 part 4: the three-state surface, and the copy tells the truth about the date
FULL PAGE ONCE PER ENTRY, NOT ONCE EVER. "Most nem" used to set a flag that
nothing ever cleared, so a box that abandoned its history and was rebuilt
months later - a genuinely NEW situation - would never see the page again. The
offer now carries an EPOCH, advanced on the edge into the offered state, and a
dismissal is recorded against the epoch it was made in. A fresh entry passes
the dismissal by arithmetic, with nothing to clear and nothing that can be
forgotten to clear.

That is NOT the flag the operator's ruling forbids. The forbidden thing
remembers that the customer decided so the screen can be suppressed while the
state stays wrong. This records WHICH SITUATION a dismissal was about.

A REAL BUG, caught by the test and not by review: the first draft returned
early from recoveryInterrupts when the offer was false, so the FALLING edge
was never recorded, RecoveryOfferActive stayed true through a settled period,
and the next entry counted as a continuation. The page never came back - the
exact defect the epoch exists to fix, reintroduced inside the fix. The sync is
now unconditional and the ordering is commented as load-bearing.

THREE LEVERS, THREE SCOPES, and none of them removes the route:
  - clicking the bar away  -> a browser SESSION cookie, cleared on login, so
    the reminder is genuinely back at the next login. Nothing persisted.
  - "ne emlekeztessen ujra" -> durable, epoch-scoped, silences the BANNER ONLY.
    It starts no countdown, abandons nothing, and a fresh entry reminds again.
  - "most nem" -> suppresses the full page only, as before.
The entry point on /backups/remote is bound to the OFFER and to nothing else,
pinned by a test that fires all three dismissals and asserts it survives.

SEC 7.3 / Q7 - THE TRAP DOES NOT SURVIVE THIS SESSION. While a recovery is
outstanding the "Helyrealitasi kod letrehozasa" button is UNAVAILABLE, not
merely captioned: creating a new code seals the current key, demotes the
package that opens the earlier history to retained custody that no shipped
path can read (R-199), and re-enables the recovery screen through the orphan
route while invalidating the code that screen accepts. A warning beside a
button is a warning people click past. The card now explains and points at
/recovery instead.

SEC 2.4 - the abandon confirmation changes with the behaviour. It used to
promise "felretesszuk - nem toroljuk". It now states the grace in days (from
the constant the countdown actually uses, never a literal in prose), that the
sealed package goes with it, that the customer can change their mind, where
the date is visible, and that the question does not come back afterwards.

The countdown is shown on /backups/remote for the WHOLE window - the bar
elsewhere is a nudge, this is the record, and a deletion date must be findable
on a quiet day too.

Tests: once-per-entry across a full settle-and-re-enter cycle; the banner
dismissal proven to be a session cookie (MaxAge 0, no Expires) and to persist
nothing; the opt-out proven to silence the banner while leaving the offer, the
route and the countdown untouched, and to remind again on a fresh entry; the
entry point surviving all three dismissals; a settled box showing nothing; and
the back-redirect refusing "//evil.example".

An existing test (TestRecovery_E) was updated: it asserted the legacy boolean,
which the epoch replaces. It now asserts the dismissal landed on the current
epoch, which is the stronger property.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:01:30 +02:00

345 lines
10 KiB
Go

package web
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type session struct {
expiresAt time.Time
csrfToken string
}
// loginAttempt tracks failed login attempts for rate limiting.
type loginAttempt struct {
count int
lastFail time.Time
}
const (
sessionCookieName = "felhom_session"
sessionMaxAge = 7 * 24 * time.Hour
loginMaxAttempts = 5
loginWindowDuration = 1 * time.Minute
)
// effectivePasswordHash returns the active password hash using the priority:
// 1. settings.json → password_hash (customer changed it)
// 2. controller.yaml → web.password_hash (operator provisioned)
// 3. Empty string → no auth required
func (s *Server) effectivePasswordHash() string {
if s.settings != nil {
if h := s.settings.GetPasswordHash(); h != "" {
return h
}
}
return s.cfg.Web.PasswordHash
}
// authEnabled returns true if a password is configured from any source.
func (s *Server) authEnabled() bool {
return s.effectivePasswordHash() != ""
}
// RequireAuth returns middleware that checks for valid session or shows login.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Customer-claim gate (v0.122.0, F-4): an unclaimed box with a delivered code hash and no
// password serves ONLY the claim page + its assets; everything else → claim page / 401.
// The claim routes (/claim, /claim/request-new-code) are handled by the mux — let them
// through so serveClaimGate only intercepts the GATED paths. A set password disables the
// gate entirely (claimGateActive returns false → the normal auth path below runs).
if s.claimGateActive() {
if claimPageAllowedPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] claim gate: intercepting %s %s (unclaimed)", r.Method, r.URL.Path)
}
s.serveClaimGate(w, r)
return
}
// Skip auth if no password is configured (legacy-open transition state, or claim disabled).
if !s.authEnabled() {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path)
}
next.ServeHTTP(w, r)
return
}
if r.URL.Path == "/api/health" {
next.ServeHTTP(w, r)
return
}
// Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET
// entry (code-gated internally). Static assets for the page too. The guest launcher share
// (v0.165.0) joins here — /s/<token> is a capability URL with NO admin session; the token
// (or the optional share password) is its own gate. Placed AFTER the claim-gate block above,
// so an unclaimed box never serves the guest page (the claim gate stays supreme).
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/s/") {
next.ServeHTTP(w, r)
return
}
if r.URL.Path == "/login" && r.Method == http.MethodPost {
s.handleLogin(w, r)
return
}
if r.URL.Path == "/login" {
s.renderLogin(w, "", r.URL.Query().Get("flash"))
return
}
if r.URL.Path == "/logout" {
s.handleLogout(w, r)
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil || !s.isValidSession(cookie.Value) {
if s.isDebug() {
reason := "no cookie"
if err == nil {
reason = "invalid/expired session"
}
s.logger.Printf("[DEBUG] [web] auth: rejected %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
}
if strings.HasPrefix(r.URL.Path, "/api/") {
s.logger.Printf("[WARN] [api] Unauthorized request to %s from %s", r.URL.Path, r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`)
return
}
// Redirect to login with ?next= so we can return to the original page
loginURL := "/login"
if r.URL.Path != "/" && r.URL.Path != "/dashboard" {
loginURL = "/login?next=" + url.QueryEscape(r.URL.RequestURI())
}
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] auth: valid session for %s %s", r.Method, r.URL.Path)
}
next.ServeHTTP(w, r)
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
password := r.FormValue("password")
nextURL := r.FormValue("next")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
}
if password == "" {
s.renderLogin(w, "Kérjük adja meg a jelszót", "")
return
}
// Rate limit: check failed attempts from this host. clientIP strips the ephemeral port
// (CAMPAIGN-4 F-B) so distinct direct connections from one host share a key and the counter
// actually accrues; XFF first-hop still wins for proxied clients.
ip := clientIP(r)
s.loginAttemptMu.Lock()
attempt := s.loginAttempts[ip]
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
// Window expired — reset
attempt = nil
delete(s.loginAttempts, ip)
}
if attempt != nil && attempt.count >= loginMaxAttempts {
s.loginAttemptMu.Unlock()
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
s.renderLogin(w, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva", "")
return
}
s.loginAttemptMu.Unlock()
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] [web] Failed login from %s", r.RemoteAddr)
s.loginAttemptMu.Lock()
if s.loginAttempts[ip] == nil {
s.loginAttempts[ip] = &loginAttempt{}
}
s.loginAttempts[ip].count++
s.loginAttempts[ip].lastFail = time.Now()
s.loginAttemptMu.Unlock()
s.renderLogin(w, "Hibás jelszó", "")
return
}
// Successful login — clear rate limit for this IP
s.loginAttemptMu.Lock()
delete(s.loginAttempts, ip)
s.loginAttemptMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login successful from %s, creating session", ip)
}
token := s.createSession()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionMaxAge.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure,
})
// R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder
// is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
// Redirect to ?next= target if provided, otherwise to dashboard
redirectTo := "/"
if nextURL != "" && strings.HasPrefix(nextURL, "/") {
redirectTo = nextURL
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
s.sessionsMu.Lock()
delete(s.sessions, cookie.Value)
s.sessionsMu.Unlock()
}
s.logger.Printf("[INFO] [web] User logged out from %s", r.RemoteAddr)
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) createSession() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
csrfB := make([]byte, 32)
_, _ = rand.Read(csrfB)
csrfToken := hex.EncodeToString(csrfB)
s.sessionsMu.Lock()
s.sessions[token] = &session{
expiresAt: time.Now().Add(sessionMaxAge),
csrfToken: csrfToken,
}
sessionCount := len(s.sessions)
s.sessionsMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] session created, expires=%s, active_sessions=%d", time.Now().Add(sessionMaxAge).Format(time.RFC3339), sessionCount)
}
return token
}
// csrfTokenForSession returns the CSRF token for the given session cookie value.
// Returns "" if the session is invalid or expired.
func (s *Server) csrfTokenForSession(sessionToken string) string {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
sess, ok := s.sessions[sessionToken]
if !ok || time.Now().After(sess.expiresAt) {
return ""
}
return sess.csrfToken
}
func (s *Server) isValidSession(token string) bool {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
sess, ok := s.sessions[token]
return ok && time.Now().Before(sess.expiresAt)
}
// invalidateAllSessions clears all sessions, forcing re-login.
// Used after password change.
func (s *Server) invalidateAllSessions() {
s.sessionsMu.Lock()
count := len(s.sessions)
s.sessions = make(map[string]*session)
s.sessionsMu.Unlock()
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
}
func (s *Server) cleanupSessions() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
expired := 0
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
expired++
}
}
remaining := len(s.sessions)
s.sessionsMu.Unlock()
if expired > 0 {
s.logger.Printf("[INFO] [web] Cleaned up %d expired sessions, %d remaining", expired, remaining)
}
if s.isDebug() && expired > 0 {
s.logger.Printf("[DEBUG] [web] session cleanup: expired=%d remaining=%d", expired, remaining)
}
}
}
}
// Close signals the server to stop background goroutines. Safe to call multiple times.
func (s *Server) Close() {
s.closeOnce.Do(func() {
close(s.done)
})
}
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
data := map[string]interface{}{
"Title": "Bejelentkezés",
"CustomerName": s.cfg.Customer.Name,
"Version": s.version, // logo ?v= cache-bust (v0.166.0)
"Error": errorMsg,
"Flash": flashMsg,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (login): %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}