package web import ( "crypto/rand" "crypto/subtle" "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 } // The anonymous language switch (v0.254.0, R-557). The sign-in page shows a globe, so the // globe has to work without a session. It writes ONE display-only cookie in the visitor's own // browser and touches nothing the household owns — langCookieHandler carries the reasoning. if r.URL.Path == langCookiePath && r.Method == http.MethodPost { s.langCookieHandler(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/ 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, "", s.flashFrom(r, "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") // The counter's key is the VISITOR (clientaddr.go, R-753): through the tunnel each visitor has its own address, so a // stranger's wrong passwords lock only the stranger. Before v0.286.0 every tunnel visitor shared cloudflared's // address here, and five wrong tries locked the whole household out of its own dashboard for a minute. ip := rateKey(r) if s.isDebug() { s.logger.Printf("[DEBUG] [web] login attempt: visitor %s (peer %s, X-Forwarded-For %q, CF-Connecting-IP %q)", ip, r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("CF-Connecting-IP")) } if password == "" { s.renderLogin(w, r, s.msg(r, "login.msg.empty_password"), "") return } // R-983 (v0.324.0): the try is RESERVED before the password is compared, under the one lock — check and count // are a single step. Before, the count was taken only after the slow compare, so N parallel tries all passed the // check and the 5-per-minute limit became "as many as arrive at once". A right password clears the counter below, // so the household loses nothing by its own try being counted first. Pinned by TestR983_ParallelLoginsHitTheLimit. if n, ok := s.reserveLoginAttempt(ip); !ok { s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, n) s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "") return } effectiveHash := s.effectivePasswordHash() if err := s.comparePassword([]byte(effectiveHash), []byte(password)); err != nil { s.logger.Printf("[WARN] [web] Failed login from %s", ip) s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "") 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", ip) // Redirect to ?next= target if provided, otherwise to dashboard. R-983 (v0.324.0): through safeBackPath, the one // guard for a destination taken from a request — a bare HasPrefix("/") let "//host" and "/\host" send a browser // that had just signed in to another site. Pinned by TestR983_LoginNextStaysOnTheBox. http.Redirect(w, r, safeBackPath(nextURL), http.StatusFound) } // reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it // may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a // check that none of them has counted yet. A window that has passed starts clean. func (s *Server) reserveLoginAttempt(ip string) (int, bool) { s.loginAttemptMu.Lock() defer s.loginAttemptMu.Unlock() if s.loginAttempts == nil { s.loginAttempts = map[string]*loginAttempt{} } a := s.loginAttempts[ip] if a != nil && time.Since(a.lastFail) > loginWindowDuration { a = nil } if a == nil { a = &loginAttempt{} s.loginAttempts[ip] = a } if a.count >= loginMaxAttempts { return a.count, false } a.count++ a.lastFail = time.Now() return a.count, true } // comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable). func (s *Server) comparePassword(hash, pw []byte) error { if s.passwordCompare != nil { return s.passwordCompare(hash, pw) } return bcrypt.CompareHashAndPassword(hash, pw) } // handleLogout ends the presented dashboard session. It runs inside RequireAuth, BEFORE CsrfProtect, so it checks the // session's CSRF token itself (R-983, v0.324.0): without it any page — another site, or an app on a sibling // subdomain, which is same-site and so gets the Lax cookie — could sign the household out. A GET changes nothing and // goes to the dashboard; the dashboard's own sign-out control is a POST form carrying the token (layout.html). // A POST with no valid session has nothing to end and only clears the cookie. Pinned by TestR983_LogoutNeedsCSRF. 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 { if expected := s.csrfTokenForSession(cookie.Value); expected != "" { submitted := r.FormValue(csrfFormField) if submitted == "" { submitted = r.Header.Get(csrfHeaderName) } if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 { s.csrfReject(w, r, "logout without the session's token") return } } s.sessionsMu.Lock() delete(s.sessions, sessionFingerprint(cookie.Value)) s.saveSessionsRevokingLocked() // R-35: a signed-out cookie stays signed out across a restart 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() // R-35: keyed by the fingerprint, never the cookie value — this map is what session_store.go writes to disk. s.sessions[sessionFingerprint(token)] = &session{ expiresAt: time.Now().Add(sessionMaxAge), csrfToken: csrfToken, } _ = s.saveSessionsLocked() 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[sessionFingerprint(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[sessionFingerprint(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.saveSessionsRevokingLocked() // R-35: the password change ends every session on disk too s.sessionsMu.Unlock() s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count) } // endEverySession is what a dashboard password change and a claim/reset do (R-976, v0.324.0): every dashboard session // ends (invalidateAllSessions), every family session — each member's and the household's — ends, and the gate key is // replaced, so every setup-gate pass and every family app cookie stops opening its app at the next request. Before, // only the dashboard sessions ended, so a session copied out of a family app kept working for up to 30 days after the // household had changed its password. Family members keep their own passwords and sign in again; the household's // browser passes again through its new dashboard session. Pinned by TestR976_PasswordChangeEndsFamilyAndGateSessions // and TestR976_ClaimResetEndsFamilyAndGateSessions. func (s *Server) endEverySession(why string) { s.invalidateAllSessions() n, err := s.familyStore().EndAll() if err != nil { s.logger.Printf("[ERROR] [web] %s: the family sessions were ended but the family list could not be saved: %v", why, err) } s.rotateGateKey() s.logger.Printf("[INFO] [web] %s: every dashboard session, %d family session(s) and every app gate pass ended", why, n) } 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) if expired > 0 { _ = s.saveSessionsLocked() } 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, r *http.Request, 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.executeTemplateLang(w, r, "login", data); err != nil { s.logger.Printf("[ERROR] [web] Template error (login): %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } }