v0.3.0: structural refactor — go:embed templates, server split, domain rename
- Migrate all 7 HTML templates + CSS from Go string constants to individual go:embed files in internal/web/templates/ (templates.go: 2150→35 lines) - Split server.go into auth.go, handlers.go, funcmap.go (server.go: 540→120 lines) - Rename controller subdomain from dashboard.* to felhom.* in Traefik labels - Update documentation (CLAUDE.md, README.md, CONTEXT.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type session struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
sessionCookieName = "felhom_session"
|
||||
sessionMaxAge = 24 * time.Hour
|
||||
)
|
||||
|
||||
// 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 == "" {
|
||||
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, "")
|
||||
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
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
password := r.FormValue("password")
|
||||
|
||||
if password == "" {
|
||||
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 {
|
||||
s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr)
|
||||
s.renderLogin(w, "Hibás jelszó")
|
||||
return
|
||||
}
|
||||
|
||||
token := s.createSession()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionMaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: true,
|
||||
})
|
||||
|
||||
s.logger.Printf("[INFO] Login from %s", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/", 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)
|
||||
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)}
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
func (s *Server) isValidSession(token string) bool {
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
|
||||
sess, ok := s.sessions[token]
|
||||
if !ok || time.Now().After(sess.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(sess.token), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) cleanupSessions() {
|
||||
for range time.Tick(15 * time.Minute) {
|
||||
s.sessionsMu.Lock()
|
||||
now := time.Now()
|
||||
for t, sess := range s.sessions {
|
||||
if now.After(sess.expiresAt) {
|
||||
delete(s.sessions, t)
|
||||
}
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) {
|
||||
data := map[string]interface{}{
|
||||
"Title": "Bejelentkezés",
|
||||
"CustomerName": s.cfg.Customer.Name,
|
||||
"Error": errorMsg,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user