// Package auth provides session-based authentication and CSRF protection // for the EMS web UI. Both the session token and CSRF token are persisted // to disk so they survive EMS restarts without logging out open browser tabs. package auth import ( "crypto/rand" "crypto/subtle" "encoding/hex" "html/template" "log/slog" "net/http" "os" "path/filepath" "strings" "sync" ) const ( cookieName = "ems_session" csrfField = "csrf_token" sessionFile = "session.token" csrfFile = "csrf.token" ) // Manager handles login, session cookies, and CSRF tokens. type Manager struct { username string password string tokenDir string mu sync.RWMutex session string csrf string logger *slog.Logger } // New creates a Manager and loads or generates persistent tokens from tokenDir. // If password is empty, authentication is disabled (open access). func New(username, password, tokenDir string, logger *slog.Logger) *Manager { m := &Manager{ username: username, password: password, tokenDir: tokenDir, logger: logger, } m.session = m.loadOrGenerate(sessionFile) m.csrf = m.loadOrGenerate(csrfFile) return m } // Enabled reports whether authentication is active. func (m *Manager) Enabled() bool { return m.password != "" } // CSRFToken returns the persistent CSRF token to embed in HTML forms. func (m *Manager) CSRFToken() string { m.mu.RLock() defer m.mu.RUnlock() return m.csrf } // RequireAuth is middleware that redirects unauthenticated requests to /login. // Exempt paths: /login, /health (health checks don't need auth). func (m *Manager) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !m.Enabled() || r.URL.Path == "/login" || r.URL.Path == "/health" { next.ServeHTTP(w, r) return } cookie, err := r.Cookie(cookieName) if err != nil || !m.validSession(cookie.Value) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } next.ServeHTTP(w, r) }) } // VerifyCSRF is middleware that checks the csrf_token field on POST requests. // On failure it redirects to / so the user gets a fresh form — avoids a // confusing error page after an EMS restart regenerates the token. func (m *Manager) VerifyCSRF(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if m.Enabled() && r.Method == http.MethodPost && r.URL.Path != "/login" && r.URL.Path != "/health" { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } got := r.FormValue(csrfField) m.mu.RLock() ok := subtle.ConstantTimeCompare([]byte(got), []byte(m.csrf)) == 1 m.mu.RUnlock() if !ok { // Redirect to status page so the user gets a fresh form. m.logger.Warn("CSRF token mismatch — redirecting to status page", "path", r.URL.Path) http.Redirect(w, r, "/", http.StatusSeeOther) return } } next.ServeHTTP(w, r) }) } // LoginHandler serves GET /login (form) and POST /login (credential check). func (m *Manager) LoginHandler() http.HandlerFunc { tmpl := template.Must(template.New("login").Parse(loginTemplate)) return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { tmpl.Execute(w, nil) //nolint:errcheck return } if r.Method != http.MethodPost { http.Redirect(w, r, "/login", http.StatusSeeOther) return } user := r.FormValue("username") pass := r.FormValue("password") uOK := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1 pOK := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1 if !uOK || !pOK { m.logger.Warn("failed login attempt", "remote_addr", r.RemoteAddr) tmpl.Execute(w, "Ungültige Zugangsdaten") //nolint:errcheck return } m.mu.RLock() session := m.session m.mu.RUnlock() // Set Secure flag when arriving via HTTPS reverse proxy. secure := r.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: session, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) m.logger.Info("web UI login", "remote_addr", r.RemoteAddr) http.Redirect(w, r, "/", http.StatusSeeOther) } } // LogoutHandler clears the session cookie and redirects to /login. func (m *Manager) LogoutHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) m.logger.Info("web UI logout", "remote_addr", r.RemoteAddr) http.Redirect(w, r, "/login", http.StatusSeeOther) } } func (m *Manager) validSession(token string) bool { m.mu.RLock() defer m.mu.RUnlock() return subtle.ConstantTimeCompare([]byte(token), []byte(m.session)) == 1 } func (m *Manager) loadOrGenerate(filename string) string { path := filepath.Join(m.tokenDir, filename) if data, err := os.ReadFile(path); err == nil { if t := strings.TrimSpace(string(data)); len(t) == 64 { return t } } token := newToken() if err := os.MkdirAll(m.tokenDir, 0o700); err == nil { _ = os.WriteFile(path, []byte(token), 0o600) } return token } func newToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("auth: cannot generate random token: " + err.Error()) } return hex.EncodeToString(b) } const loginTemplate = `