95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
311 lines
8.3 KiB
Go
311 lines
8.3 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) {
|
|
// 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/") {
|
|
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] 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] 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] 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()
|
|
}
|
|
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()
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] invalidated all sessions (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 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] Template error (login): %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|