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 } const ( sessionCookieName = "felhom_session" sessionMaxAge = 7 * 24 * time.Hour ) // 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() { 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 strings.HasPrefix(r.URL.Path, "/api/") { 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 } 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 password == "" { s.renderLogin(w, "Kérjük adja meg a jelszót", "") return } effectiveHash := s.effectivePasswordHash() if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil { s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr) s.renderLogin(w, "Hibás jelszó", "") return } 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] 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 cookie, err := r.Cookie(sessionCookieName); err == nil { s.sessionsMu.Lock() delete(s.sessions, cookie.Value) s.sessionsMu.Unlock() } 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, } s.sessionsMu.Unlock() 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() s.sessions = make(map[string]*session) s.sessionsMu.Unlock() } 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() for t, sess := range s.sessions { if now.After(sess.expiresAt) { delete(s.sessions, t) } } s.sessionsMu.Unlock() } } } // 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] Template error (login): %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } }