v0.7.0: Phase 1 — Authentication, Persistence & Settings Page

- New settings.json persistence layer (internal/settings/settings.go)
  - Atomic write (tmp + rename), thread-safe with sync.RWMutex
  - Stores password hash overrides and DB validation cache
  - Auto-creates on first save, graceful handling if missing

- Auth improvements
  - Password resolution priority: settings.json > controller.yaml > none
  - Session duration extended to 7 days (was 24h)
  - ?next= redirect after session expiry (returns to original page)
  - Flash messages on login page (used after password change)
  - Conditional logout link (hidden when auth disabled)
  - Session invalidation on password change

- New Settings page (/settings)
  - Read-only system config display (customer, domain, git, backup, monitoring)
  - Password change form with validation (min 8 chars, match check)
  - Sidebar "Beállítások" item pinned to bottom above version

- DB validation persistence
  - Validation results saved to settings.json after each dump
  - Cached data survives container restarts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 17:26:59 +01:00
parent 0be1f2e547
commit 4053245be8
10 changed files with 514 additions and 25 deletions
+50 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -17,14 +18,32 @@ type session struct {
const (
sessionCookieName = "felhom_session"
sessionMaxAge = 24 * time.Hour
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.cfg.Web.PasswordHash == "" {
if !s.authEnabled() {
next.ServeHTTP(w, r)
return
}
@@ -39,7 +58,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
if r.URL.Path == "/login" {
s.renderLogin(w, "")
s.renderLogin(w, "", r.URL.Query().Get("flash"))
return
}
if r.URL.Path == "/logout" {
@@ -55,7 +74,12 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
// 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
}
@@ -66,15 +90,17 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
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")
s.renderLogin(w, "Kérjük adja meg a jelszót", "")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil {
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ó")
s.renderLogin(w, "Hibás jelszó", "")
return
}
@@ -91,7 +117,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
})
s.logger.Printf("[INFO] Login from %s", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusFound)
// 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) {
@@ -123,6 +155,14 @@ func (s *Server) isValidSession(token string) bool {
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()
@@ -148,11 +188,12 @@ func (s *Server) Close() {
close(s.done)
}
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) {
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 {