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 {
+88
View File
@@ -3,10 +3,12 @@ package web
import (
"fmt"
"net/http"
"net/url"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) baseData(page, title string) map[string]interface{} {
@@ -16,6 +18,7 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
"CustomerName": s.cfg.Customer.Name,
"Domain": s.cfg.Customer.Domain,
"Version": s.version,
"AuthEnabled": s.authEnabled(),
}
}
@@ -210,3 +213,88 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
s.render(w, "backups", data)
}
func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("settings", "Beállítások")
// System configuration (read-only display from controller.yaml)
data["CustomerID"] = s.cfg.Customer.ID
data["CustomerDomain"] = s.cfg.Customer.Domain
data["GitRepoURL"] = s.cfg.Git.RepoURL
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
data["BackupEnabled"] = s.cfg.Backup.Enabled
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
data["HubEnabled"] = s.cfg.Hub.Enabled
s.render(w, "settings", data)
}
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
data := s.baseData("settings", "Beállítások")
data["CustomerID"] = s.cfg.Customer.ID
data["CustomerDomain"] = s.cfg.Customer.Domain
data["GitRepoURL"] = s.cfg.Git.RepoURL
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
data["BackupEnabled"] = s.cfg.Backup.Enabled
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
data["HubEnabled"] = s.cfg.Hub.Enabled
// Validate current password
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
data["PasswordError"] = "Hibás jelenlegi jelszó"
s.render(w, "settings", data)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
s.render(w, "settings", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
s.render(w, "settings", data)
return
}
// Generate bcrypt hash
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.render(w, "settings", data)
return
}
// Save to settings.json
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
s.render(w, "settings", data)
return
}
s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr)
// Invalidate all sessions (force re-login)
s.invalidateAllSessions()
// Redirect to login with flash message
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
}
+18 -1
View File
@@ -13,6 +13,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
@@ -23,6 +24,7 @@ type Server struct {
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
scheduler *scheduler.Scheduler
settings *settings.Settings
logger *log.Logger
version string
tmpl *template.Template
@@ -32,13 +34,14 @@ type Server struct {
done chan struct{}
}
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, logger *log.Logger, version string) *Server {
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
stackMgr: stackMgr,
cpuCollector: cpuCollector,
backupMgr: backupMgr,
scheduler: sched,
settings: sett,
logger: logger,
version: version,
sessions: make(map[string]*session),
@@ -46,6 +49,16 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
}
s.loadTemplates()
go s.cleanupSessions()
// Log auth source on startup
if sett != nil && sett.GetPasswordHash() != "" {
logger.Printf("[INFO] Auth: using password from settings.json")
} else if cfg.Web.PasswordHash != "" {
logger.Printf("[INFO] Auth: using password from controller.yaml")
} else {
logger.Printf("[INFO] Auth: no password configured — dashboard is open")
}
return s
}
@@ -68,6 +81,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.backupsHandler(w, r)
case path == "/monitoring":
s.monitoringHandler(w, r)
case path == "/settings":
s.settingsHandler(w, r)
case path == "/settings/password" && r.Method == http.MethodPost:
s.settingsPasswordHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/logs")
@@ -19,9 +19,12 @@
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}">Rendszermonitor</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">v{{.Version}}</span>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
<div class="sidebar-bottom">
<a href="/settings" class="sidebar-settings-link {{if eq .Page "settings"}}active{{end}}">⚙ Beállítások</a>
<div class="sidebar-footer">
<span class="version">v{{.Version}}</span>
{{if .AuthEnabled}}<a href="/logout" class="logout-link">Kijelentkezés ↗</a>{{end}}
</div>
</div>
</nav>
<main class="content">
@@ -11,8 +11,10 @@
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<p class="login-subtitle">{{.CustomerName}}</p>
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
<input type="hidden" name="next" id="next-field" value="">
<div class="form-group">
<label for="password">Jelszó</label>
<input type="password" id="password" name="password" required autofocus
@@ -23,6 +25,15 @@
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
</div>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var next = params.get('next');
if (next) {
document.getElementById('next-field').value = next;
}
})();
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,97 @@
{{define "settings"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Beállítások</h2>
</div>
<!-- Section A: System Configuration (read-only) -->
<div class="settings-card">
<h3>Rendszer konfiguráció</h3>
<p class="settings-card-desc">Az üzemeltető által beállított értékek. Módosításhoz kérd az üzemeltetőt.</p>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">Ügyfél azonosító</span>
<span class="settings-value mono">{{.CustomerID}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Ügyfél neve</span>
<span class="settings-value">{{.CustomerName}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Domain</span>
<span class="settings-value mono">{{.CustomerDomain}}</span>
</div>
{{if .GitRepoURL}}
<div class="settings-row">
<span class="settings-label">Alkalmazás sablon forrás</span>
<span class="settings-value mono settings-value-truncate">{{.GitRepoURL}}</span>
</div>
{{end}}
<div class="settings-row">
<span class="settings-label">Sablon szinkronizálás</span>
<span class="settings-value mono">{{.GitSyncInterval}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Biztonsági mentés</span>
<span class="settings-value">{{if .BackupEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}<span class="state-text-red">❌ Inaktív</span>{{end}}</span>
</div>
{{if .BackupEnabled}}
<div class="settings-row">
<span class="settings-label">Mentés ütemezés</span>
<span class="settings-value mono">{{.DBDumpSchedule}} / {{.ResticSchedule}}</span>
</div>
{{end}}
<div class="settings-row">
<span class="settings-label">Monitoring</span>
<span class="settings-value">{{if .MonitoringEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}<span class="state-text-red">❌ Inaktív</span>{{end}}</span>
</div>
{{if .MonitoringEnabled}}
<div class="settings-row">
<span class="settings-label">Healthchecks URL</span>
<span class="settings-value mono settings-value-truncate">{{if .HealthchecksBase}}{{.HealthchecksBase}}{{else}}{{end}}</span>
</div>
{{end}}
<div class="settings-row">
<span class="settings-label">Hub jelentés</span>
<span class="settings-value">{{if .HubEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}{{end}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Controller verzió</span>
<span class="settings-value mono">v{{.Version}}</span>
</div>
</div>
</div>
<!-- Section B: Password Change -->
<div class="settings-card">
<h3>Jelszó módosítás</h3>
{{if .AuthEnabled}}
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
<form method="POST" action="/settings/password">
<div class="form-group">
<label for="current_password">Jelenlegi jelszó</label>
<input type="password" id="current_password" name="current_password" required
placeholder="Adja meg a jelenlegi jelszavát" class="form-control">
</div>
<div class="form-group">
<label for="new_password">Új jelszó</label>
<input type="password" id="new_password" name="new_password" required minlength="8"
placeholder="Legalább 8 karakter" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">Új jelszó megerősítése</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="8"
placeholder="Jelszó mégegyszer" class="form-control">
</div>
<button type="submit" class="btn btn-primary">Jelszó módosítása</button>
</form>
{{else}}
<div class="alert alert-info">
A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz.
</div>
{{end}}
</div>
{{template "layout_end" .}}
{{end}}
@@ -1611,6 +1611,82 @@ a.stat-card:hover {
font-family: 'JetBrains Mono', monospace;
}
/* --- Settings page --- */
.settings-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.5rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.settings-card h3 {
margin-bottom: .5rem;
}
.settings-card-desc {
color: var(--text-secondary);
font-size: .85rem;
margin-bottom: 1rem;
}
.settings-grid {
display: flex;
flex-direction: column;
}
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: .5rem .75rem;
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
font-size: .9rem;
gap: 1rem;
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.settings-value {
color: var(--text-primary);
text-align: right;
word-break: break-word;
}
.settings-value.mono {
font-family: 'JetBrains Mono', monospace;
font-size: .85rem;
}
.settings-value-truncate {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sidebar bottom section */
.sidebar-bottom {
margin-top: auto;
border-top: 1px solid var(--border-color);
}
.sidebar-settings-link {
display: block;
padding: .75rem 1.5rem;
color: var(--text-secondary);
text-decoration: none;
font-size: .95rem;
font-weight: 500;
transition: color 0.2s ease, background 0.2s ease;
}
.sidebar-settings-link:hover {
color: var(--accent-light);
background: rgba(0, 136, 204, 0.08);
}
.sidebar-settings-link.active {
color: var(--accent-light);
background: rgba(0, 136, 204, 0.12);
border-left: 3px solid var(--accent-blue);
}
/* Responsive */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }