Files
felhom-controller/controller/internal/web/auth.go
T
admin ac149c2aa4 i18n slice 1 release C: storage, sharing, sign-in, guest, catch-all and debug pages in English
- 11 templates converted (storage, storage_network, storage_init, storage_attach, sharing, login,
  claim, launcher_shared, launcher_share_password, catchall, debug); every key translated.
  Six ASCII-only Hungarian JS fragments the extractor missed were found by eye and converted by hand
  (", majd a(z)", "FIGYELEM:", "jelenlegi:", "mp", "p", " db").
- login, claim, both guest share pages and the catch-all render through executeTemplateLang (the
  household language; no session CSRF, no escrow reminder). renderLogin now takes the request.
- Page titles: TitleKey for storage, network storage, the two drive wizards and sharing.
  TestHandlerTitleKeysMatchHungarianTitle pins handler literal == hu.json value for every TitleKey.
- Tests: TestDirectRenderHandlersFollowLanguage (real routes, en + hu),
  TestI18nDirectRenderPagesHaveNoAdminChrome (escrow reminder due; none on the guest page).
- Fixture correction: the release C cases had invented page titles; the cases now carry the handlers'
  real titles (and the wizard pages their real page name), and those 12 fixtures were RE-CAPTURED from
  the unconverted templates at 0555091. The diff to the old fixtures is the <title> line (and the nav
  highlight on the two wizard pages) only.
- i18n gate: HU_FORMAL_CEILING 12 -> 16 — four more „ön" forms in the converted copy, unchanged by rule (R-516).

Hungarian: byte-identical (TestI18nParity against fixtures from unconverted templates).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:29:53 +02:00

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, "", 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 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)
}
}