package web import ( "crypto/hmac" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "errors" "net/http" "net/url" "strconv" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/family" ) // ── The family gate's answerer (v0.287.0, `09` §3 decisions 63 and 64; R-780) ───────────────────────────── // // traefik asks GET /__felhom_gate/family (forwardAuth) for every request to a family-gated app (except the anchored // paths the template leaves to the app's own login — internal/stacks/family_gate.go writes that file). The answer: // // - a valid FAMILY APP COOKIE (`felhom_famgate`, host-only on the app host) → 200; // - /__felhom_gate/fcb?t= → the token (60 s, one use, bound to the host and to a family session) becomes the // app cookie, and the browser goes back where it was going; // - a browser GET without one → 302 to https://felhom./__family/start?rd=; // - anything else → 401 JSON. // // On the DASHBOARD host, outside the dashboard's own auth (CatchAllMiddleware answers them first): // // - /__family/start — with a valid FAMILY SESSION (`felhom_family`, Path=/__family, so the browser never even sends // it to a dashboard page) or the household's dashboard session: a token and a 302 to the app; without: the family // sign-in page; // - /__family/login — a member's OWN name and password; counted per VISITOR (clientIP, R-753) and per NAME, short // windows, never "everyone"; // - /__family/logout — ends the family session, and with it every app cookie minted from it (the store is asked on // every request). // // THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets // it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next // request on every app. Pinned by internal/web/family_gate_test.go. const ( familySessionCookie = "felhom_family" familyAppCookie = "felhom_famgate" familyAuthPath = "/__felhom_gate/family" familyCallbackURI = "/__felhom_gate/fcb" familyStartPath = "/__family/start" familyLoginPath = "/__family/login" familyLogoutPath = "/__family/logout" familyCookiePath = "/__family" familyFormLife = time.Hour familyVisitorMax = 5 familyVisitorWindow = time.Minute familyNameMax = 10 familyNameWindow = 10 * time.Minute ) type familyState struct { once sync.Once store *family.Store mu sync.Mutex visitor map[string][]time.Time name map[string][]time.Time } // familyStore opens the family list once. An unreadable list is logged and answers "nobody" (fail closed — the gate // stays shut; the household still passes with its dashboard session). func (s *Server) familyStore() *family.Store { s.fam.once.Do(func() { s.fam.visitor = map[string][]time.Time{} s.fam.name = map[string][]time.Time{} if s.familyStoreOverride != nil { s.fam.store = s.familyStoreOverride return } if s.cfg == nil || s.cfg.Paths.DataDir == "" { return } st, err := family.Open(s.cfg.Paths.DataDir) if err != nil { s.logger.Printf("[ERROR] [web] family gate: the family list could not be read (%v) — only the household passes until it is fixed", err) return } s.fam.store = st }) return s.fam.store } // familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both. func (s *Server) familyLocked(visitor, name string) bool { s.familyStore() now := s.gateNow() s.fam.mu.Lock() defer s.fam.mu.Unlock() prune := func(m map[string][]time.Time, k string, win time.Duration) int { var keep []time.Time for _, t := range m[k] { if now.Sub(t) < win { keep = append(keep, t) } } if len(keep) == 0 { delete(m, k) } else { m[k] = keep } return len(keep) } v := prune(s.fam.visitor, visitor, familyVisitorWindow) n := 0 if name != "" { n = prune(s.fam.name, name, familyNameWindow) } return v >= familyVisitorMax || n >= familyNameMax } func (s *Server) familyFailed(visitor, name string) { now := s.gateNow() s.fam.mu.Lock() defer s.fam.mu.Unlock() s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now) if name != "" { s.fam.name[name] = append(s.fam.name[name], now) } } func (s *Server) familyCleared(visitor, name string) { s.fam.mu.Lock() defer s.fam.mu.Unlock() delete(s.fam.visitor, visitor) delete(s.fam.name, name) } // familyRDHost: the host of a return address that may be used — https, on this household's domain, a family-gated app. func (s *Server) familyRDHost(rd string) (string, bool) { u, err := url.Parse(rd) if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil || s.cfg == nil { return "", false } host := strings.ToLower(u.Hostname()) if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) { return "", false } if _, found := s.stackMgr.FamilyGateHost(host); !found { return "", false } return host, true } type familyToken struct { Host string `json:"h"` Sid string `json:"s"` Exp int64 `json:"e"` Nonce string `json:"n"` RD string `json:"r"` MAC string `json:"m"` } func (s *Server) mintFamilyToken(host, sid, rd string) string { nb := make([]byte, 12) _, _ = rand.Read(nb) t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD) b, _ := json.Marshal(t) return base64.RawURLEncoding.EncodeToString(b) } // takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going. func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) { b, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { return "", "", errors.New("malformed") } var t familyToken if json.Unmarshal(b, &t) != nil { return "", "", errors.New("malformed") } if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) { return "", "", errors.New("bad signature") } if t.Host != host { return "", "", errors.New("for another app") } now := s.gateNow() if now.Unix() > t.Exp { return "", "", errors.New("expired") } s.gateKey() s.gate.mu.Lock() defer s.gate.mu.Unlock() for n, until := range s.gate.used { if now.After(until) { delete(s.gate.used, n) } } if _, seen := s.gate.used["f:"+t.Nonce]; seen { return "", "", errors.New("already used") } s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second) return t.Sid, t.RD, nil } // familyAppCookieSession: ".." → the session, if the cookie is genuine for // this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here). func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) { c, err := r.Cookie(familyAppCookie) if err != nil { return family.Session{}, false } parts := strings.Split(c.Value, ".") if len(parts) != 3 { return family.Session{}, false } n, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || s.gateNow().Unix() > n { return family.Session{}, false } if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) { return family.Session{}, false } return s.familyStore().Valid(parts[0]) } func familyRefuse(w http.ResponseWriter, code int) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) _, _ = w.Write([]byte(`{"error":"sign in with your family login"}`)) } // ServeFamilyGateAuth is traefik's forwardAuth answer for a family-gated app. Like the setup gate it trusts only the // X-Forwarded-Host/-Uri/-Method traefik writes from the request it forwards (forwardAuth's own, never the client's). func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) { host := strings.ToLower(r.Header.Get("X-Forwarded-Host")) if i := strings.LastIndex(host, ":"); i != -1 { host = host[:i] } uri := r.Header.Get("X-Forwarded-Uri") if uri == "" { uri = "/" } method := r.Header.Get("X-Forwarded-Method") if s.stackMgr == nil { familyRefuse(w, http.StatusForbidden) return } app, found := s.stackMgr.FamilyGateHost(host) if !found { s.logger.Printf("[WARN] [web] family gate: asked about %q, which no family app owns — refused", host) familyRefuse(w, http.StatusForbidden) return } if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI { sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host) if err == nil { if _, ok := s.familyStore().Valid(sid); !ok { err = errors.New("the session ended") } } if err != nil { s.logger.Printf("[WARN] [web] family gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r)) familyRefuse(w, http.StatusForbidden) return } exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10) http.SetCookie(w, &http.Cookie{ Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/", MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r)) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, rd, http.StatusFound) return } if _, ok := s.familyAppCookieSession(r, host); ok { w.WriteHeader(http.StatusOK) return } if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") { w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+familyStartPath+"?"+url.Values{"rd": {"https://" + host + uri}}.Encode(), http.StatusFound) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] family gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r)) } familyRefuse(w, http.StatusUnauthorized) } // familySessionFromCookie: the family session this browser holds on the dashboard host. func (s *Server) familySessionFromCookie(r *http.Request) (family.Session, bool) { c, err := r.Cookie(familySessionCookie) if err != nil { return family.Session{}, false } return s.familyStore().Valid(c.Value) } // ServeFamilyStart is /__family/start on the dashboard host. func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) { rd := r.URL.Query().Get("rd") host, ok := s.familyRDHost(rd) if !ok { s.renderFamilyPage(w, r, "", "", http.StatusOK) return } w.Header().Set("Cache-Control", "no-store") se, ok := s.familySessionFromCookie(r) if !ok && s.hasSession(r) { // The household's own dashboard session vouches (decision 46's rule) — a household session in the family store, // never the dashboard session itself. if st := s.familyStore(); st != nil { if sid, err := st.NewSession(""); err == nil { se, ok = family.Session{ID: sid}, true } } } if ok { http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound) return } s.renderFamilyPage(w, r, rd, "", http.StatusOK) } // familyFormToken is the sign-in form's own CSRF (the visitor has no session): an HMAC over its expiry. func (s *Server) familyFormToken() string { exp := strconv.FormatInt(s.gateNow().Add(familyFormLife).Unix(), 10) return exp + "." + s.gateMAC("famform", exp) } func (s *Server) familyFormTokenValid(v string) bool { exp, mac, ok := strings.Cut(v, ".") n, err := strconv.ParseInt(exp, 10, 64) if !ok || err != nil || s.gateNow().Unix() > n { return false } return hmac.Equal([]byte(mac), []byte(s.gateMAC("famform", exp))) } // ServeFamilyLogin is /__family/login: GET = the page, POST = a member's own name and password. func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { s.renderFamilyPage(w, r, r.URL.Query().Get("rd"), "", http.StatusOK) return } _ = r.ParseForm() rd := r.FormValue("rd") if !s.familyFormTokenValid(r.FormValue("_ft")) { s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden) return } name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) visitor := rateKey(r) if s.familyLocked(visitor, name) { s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name) s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests) return } st := s.familyStore() if st == nil || !st.Verify(name, r.FormValue("password")) { s.familyFailed(visitor, name) s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor) s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized) return } sid, err := st.NewSession(name) if err != nil { s.logger.Printf("[ERROR] [web] family sign-in: the session could not be saved: %v", err) s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusInternalServerError) return } s.familyCleared(visitor, name) http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: sid, Path: familyCookiePath, MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) s.logger.Printf("[INFO] [web] family sign-in: %s — visitor %s", name, visitor) if _, ok := s.familyRDHost(rd); ok { http.Redirect(w, r, familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound) return } s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK) } // ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it. func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(familySessionCookie); err == nil { if err := s.familyStore().EndSession(c.Value); err != nil { s.logger.Printf("[ERROR] [web] family sign-out: %v", err) } } http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true}) s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK) } func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, notice string, code int) { data := map[string]interface{}{"RD": rd, "Notice": notice, "FormToken": s.familyFormToken(), "Version": s.version} if host, ok := s.familyRDHost(rd); ok { data["Host"] = host if app, found := s.stackMgr.FamilyGateHost(host); found { if st, ok := s.stackMgr.GetStack(app); ok { data["AppName"] = st.Meta.DisplayName } } } if _, ok := s.familySessionFromCookie(r); ok { data["SignedIn"] = true } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) if err := s.executeTemplateLang(w, r, "familygate", data); err != nil { s.logger.Printf("[ERROR] [web] family page: %v", err) } } // ── the dashboard's „Család" card (authenticated, CSRF-protected) ─────────────────────────────────────────── func (s *Server) familyMembersHandler(w http.ResponseWriter, r *http.Request) { st := s.familyStore() if st == nil { escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable")) return } escrowJSON(w, http.StatusOK, map[string]any{"members": st.Names()}, "") } // familyMemberActionHandler: POST /family/members/{add,reset,remove} with `name`. add/reset answer the new password // ONCE (never logged, never in a page render). func (s *Server) familyMemberActionHandler(w http.ResponseWriter, r *http.Request, action string) { st := s.familyStore() if st == nil { escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable")) return } _ = r.ParseForm() name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) w.Header().Set("Cache-Control", "no-store") var pw string var err error switch action { case "add": pw, err = st.Add(name) case "reset": pw, err = st.Reset(name) case "remove": err = st.Remove(name) default: escrowJSON(w, http.StatusNotFound, nil, "") return } switch { case errors.Is(err, family.ErrBadName): escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "family_gate.msg.bad_name")) return case errors.Is(err, family.ErrExists): escrowJSON(w, http.StatusConflict, nil, s.msg(r, "family_gate.msg.exists")) return case errors.Is(err, family.ErrNoMember): escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "family_gate.msg.no_member")) return case err != nil: s.logger.Printf("[ERROR] [web] family %s %q: %v", action, name, err) escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "family_gate.msg.store_unreadable")) return } s.logger.Printf("[INFO] [web] family: the household's %s of %q from %s (password never logged)", action, name, clientIP(r)) out := map[string]any{"name": name, "members": st.Names()} if pw != "" { out["password"] = pw } escrowJSON(w, http.StatusOK, out, "") }