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) { // Skip auth if no password is configured 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 } 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 IP ip := r.RemoteAddr if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { ip = strings.Split(fwd, ",")[0] } ip = strings.TrimSpace(ip) 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, }) 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, "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) } }