5270bad76e
gates / gates (push) Successful in 23s
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still Hungarian literals in Go, so an English household clicked an English button and was answered in Hungarian. 226 of them move into the bundle here. A flash was the hard part: it travels inside the redirect URL and is rendered by a DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by an older controller carries prose and is shown verbatim — never a raw key, never dropped. Also converted: page data and view-model text, the internal/api JSON answers, the alert banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at display, and the four page titles built around an app name (R-566 closed). Hungarian is byte-identical, and that is measured rather than read: scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict. Its own first version filtered the capture through an ASCII-Hungarian word list and missed seven real literals — the R-565 class. The filter is gone. Nothing on the wire moved, and wire goldens now hold it there: the report's health warnings and every notify event message stay Hungarian, because the hub MAILS the controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
345 lines
11 KiB
Go
345 lines
11 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
|
|
}
|
|
|
|
// loginAttempt tracks failed login attempts for rate limiting.
|
|
type loginAttempt struct {
|
|
count int
|
|
lastFail time.Time
|
|
}
|
|
|
|
const (
|
|
sessionCookieName = "felhom_session"
|
|
sessionMaxAge = 7 * 24 * time.Hour
|
|
loginMaxAttempts = 5
|
|
loginWindowDuration = 1 * time.Minute
|
|
)
|
|
|
|
// 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) {
|
|
// Customer-claim gate (v0.122.0, F-4): an unclaimed box with a delivered code hash and no
|
|
// password serves ONLY the claim page + its assets; everything else → claim page / 401.
|
|
// The claim routes (/claim, /claim/request-new-code) are handled by the mux — let them
|
|
// through so serveClaimGate only intercepts the GATED paths. A set password disables the
|
|
// gate entirely (claimGateActive returns false → the normal auth path below runs).
|
|
if s.claimGateActive() {
|
|
if claimPageAllowedPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] claim gate: intercepting %s %s (unclaimed)", r.Method, r.URL.Path)
|
|
}
|
|
s.serveClaimGate(w, r)
|
|
return
|
|
}
|
|
|
|
// Skip auth if no password is configured (legacy-open transition state, or claim disabled).
|
|
if !s.authEnabled() {
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if r.URL.Path == "/api/health" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET
|
|
// entry (code-gated internally). Static assets for the page too. The guest launcher share
|
|
// (v0.165.0) joins here — /s/<token> is a capability URL with NO admin session; the token
|
|
// (or the optional share password) is its own gate. Placed AFTER the claim-gate block above,
|
|
// so an unclaimed box never serves the guest page (the claim gate stays supreme).
|
|
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/s/") {
|
|
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, "", s.flashFrom(r, "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 s.isDebug() {
|
|
reason := "no cookie"
|
|
if err == nil {
|
|
reason = "invalid/expired session"
|
|
}
|
|
s.logger.Printf("[DEBUG] [web] auth: rejected %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
s.logger.Printf("[WARN] [api] Unauthorized request to %s from %s", r.URL.Path, r.RemoteAddr)
|
|
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
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] auth: valid session for %s %s", r.Method, r.URL.Path)
|
|
}
|
|
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 s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
|
}
|
|
|
|
if password == "" {
|
|
s.renderLogin(w, r, "Kérjük adja meg a jelszót", "")
|
|
return
|
|
}
|
|
|
|
// Rate limit: check failed attempts from this host. clientIP strips the ephemeral port
|
|
// (CAMPAIGN-4 F-B) so distinct direct connections from one host share a key and the counter
|
|
// actually accrues; XFF first-hop still wins for proxied clients.
|
|
ip := clientIP(r)
|
|
|
|
s.loginAttemptMu.Lock()
|
|
attempt := s.loginAttempts[ip]
|
|
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
|
|
// Window expired — reset
|
|
attempt = nil
|
|
delete(s.loginAttempts, ip)
|
|
}
|
|
if attempt != nil && attempt.count >= loginMaxAttempts {
|
|
s.loginAttemptMu.Unlock()
|
|
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
|
|
s.renderLogin(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva", "")
|
|
return
|
|
}
|
|
s.loginAttemptMu.Unlock()
|
|
|
|
effectiveHash := s.effectivePasswordHash()
|
|
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
|
|
s.logger.Printf("[WARN] [web] Failed login from %s", r.RemoteAddr)
|
|
s.loginAttemptMu.Lock()
|
|
if s.loginAttempts[ip] == nil {
|
|
s.loginAttempts[ip] = &loginAttempt{}
|
|
}
|
|
s.loginAttempts[ip].count++
|
|
s.loginAttempts[ip].lastFail = time.Now()
|
|
s.loginAttemptMu.Unlock()
|
|
s.renderLogin(w, r, "Hibás jelszó", "")
|
|
return
|
|
}
|
|
|
|
// Successful login — clear rate limit for this IP
|
|
s.loginAttemptMu.Lock()
|
|
delete(s.loginAttempts, ip)
|
|
s.loginAttemptMu.Unlock()
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] login successful from %s, creating session", ip)
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
// R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder
|
|
// is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the
|
|
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
|
|
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
|
|
|
|
s.logger.Printf("[INFO] [web] 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 r.Method != http.MethodPost {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
|
|
}
|
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
|
s.sessionsMu.Lock()
|
|
delete(s.sessions, cookie.Value)
|
|
s.sessionsMu.Unlock()
|
|
}
|
|
s.logger.Printf("[INFO] [web] User logged out from %s", r.RemoteAddr)
|
|
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,
|
|
}
|
|
sessionCount := len(s.sessions)
|
|
s.sessionsMu.Unlock()
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] session created, expires=%s, active_sessions=%d", time.Now().Add(sessionMaxAge).Format(time.RFC3339), sessionCount)
|
|
}
|
|
|
|
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()
|
|
count := len(s.sessions)
|
|
s.sessions = make(map[string]*session)
|
|
s.sessionsMu.Unlock()
|
|
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
|
|
}
|
|
|
|
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()
|
|
expired := 0
|
|
for t, sess := range s.sessions {
|
|
if now.After(sess.expiresAt) {
|
|
delete(s.sessions, t)
|
|
expired++
|
|
}
|
|
}
|
|
remaining := len(s.sessions)
|
|
s.sessionsMu.Unlock()
|
|
if expired > 0 {
|
|
s.logger.Printf("[INFO] [web] Cleaned up %d expired sessions, %d remaining", expired, remaining)
|
|
}
|
|
if s.isDebug() && expired > 0 {
|
|
s.logger.Printf("[DEBUG] [web] session cleanup: expired=%d remaining=%d", expired, remaining)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close signals the server to stop background goroutines. Safe to call multiple times.
|
|
func (s *Server) Close() {
|
|
s.closeOnce.Do(func() {
|
|
close(s.done)
|
|
})
|
|
}
|
|
|
|
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) {
|
|
data := map[string]interface{}{
|
|
"Title": "Bejelentkezés",
|
|
"CustomerName": s.cfg.Customer.Name,
|
|
"Version": s.version, // logo ?v= cache-bust (v0.166.0)
|
|
"Error": errorMsg,
|
|
"Flash": flashMsg,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.executeTemplateLang(w, r, "login", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (login): %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|