02650e3202
Controller: - internal/web/csrf.go (new): CsrfProtect middleware, csrfToken/csrfField helpers - auth.go: per-session CSRF token (csrfToken field, csrfTokenForSession method) - server.go: executeTemplate wrapper auto-injects CSRFField+CSRFToken - main.go: wire CsrfProtect on all routes; bump to v0.23.0 - handlers.go, storage_handlers.go, handler_restore.go: executeTemplate - All templates: CSRFField in forms, meta csrf-token, csrfHeaders() JS helper, fetch calls updated; sendBeacon→fetch+keepalive in storage_attach.html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
5.6 KiB
Go
224 lines
5.6 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
|
|
}
|
|
|
|
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 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.
|
|
func (s *Server) Close() {
|
|
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)
|
|
}
|
|
}
|