b4e0a197f9
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
489 lines
18 KiB
Go
489 lines
18 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Customer-claim password arc (v0.122.0, DRILL-day0-vm F-4). The customer OWNS the dashboard
|
|
// password: an unclaimed box serves ONLY the claim page (code → set own password → claimed);
|
|
// everything else answers the claim page (HTML) or 401 (API). A set password always wins (the
|
|
// gate never shows once effectivePasswordHash != ""). Reset rides the same code engine. A box
|
|
// with a code hash but no password and not-yet-claimed is GATED; a box with neither hash nor
|
|
// password is legacy-open with a red transition banner (transitional only).
|
|
|
|
const (
|
|
claimCodeTTL = 72 * time.Hour
|
|
claimMinPassword = 12
|
|
claimMaxAttempts = 5
|
|
claimLockoutWindow = 15 * time.Minute
|
|
claimCSRFCookie = "felhom_claim_csrf"
|
|
)
|
|
|
|
// claimAttempt tracks failed claim-code attempts for the per-source + global limiter.
|
|
type claimAttempt struct {
|
|
count int
|
|
lockedTill time.Time
|
|
}
|
|
|
|
// effectiveClaimCode returns the freshest hub-delivered claim-code state: the ACK-cached
|
|
// settings value when its generation is at least the config-baked one (fresher), else the
|
|
// controller.yaml bake. Returns ("", 0, "") when neither carries a code.
|
|
func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string) {
|
|
var sHash, sIssued string
|
|
var sGen int
|
|
if s.settings != nil {
|
|
sHash, sGen, sIssued = s.settings.GetClaimCode()
|
|
}
|
|
cHash := s.cfg.Web.ClaimCodeHash
|
|
cGen := s.cfg.Web.ClaimCodeGeneration
|
|
cIssued := s.cfg.Web.ClaimCodeIssuedAt
|
|
if sHash != "" && sGen >= cGen {
|
|
return sHash, sGen, sIssued
|
|
}
|
|
return cHash, cGen, cIssued
|
|
}
|
|
|
|
// claimGateActive reports whether the unclaimed-gate applies: no password set anywhere, a claim
|
|
// code hash is present, and the box has not been claimed. A set password (settings or config)
|
|
// disables the gate entirely — password auth wins.
|
|
func (s *Server) claimGateActive() bool {
|
|
if s.authEnabled() {
|
|
return false // a password beats the gate (claimed boxes, or an operator-set one)
|
|
}
|
|
hash, _, _ := s.effectiveClaimCode()
|
|
if hash == "" {
|
|
return false // legacy-open (transition state) — no code to gate on
|
|
}
|
|
if s.settings != nil && s.settings.GetClaimed() {
|
|
return false // claimed but password somehow cleared — don't re-gate; treat as legacy-open
|
|
}
|
|
return true
|
|
}
|
|
|
|
// claimLegacyOpen reports the transitional open state: no password, no code hash — the red
|
|
// banner is shown until the hub delivers a code hash. NOT the fresh-box state (that is gated).
|
|
func (s *Server) claimLegacyOpen() bool {
|
|
if s.authEnabled() {
|
|
return false
|
|
}
|
|
hash, _, _ := s.effectiveClaimCode()
|
|
return hash == ""
|
|
}
|
|
|
|
// ── pre-auth CSRF for the claim form (closes CTRL-007: HMAC with the server-side session
|
|
// secret, not a bare double-submit) ────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) claimCSRFToken() string {
|
|
mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
|
|
mac.Write([]byte("felhom-claim-csrf-v1"))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (s *Server) setClaimCSRFCookie(w http.ResponseWriter, r *http.Request) string {
|
|
tok := s.claimCSRFToken()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: claimCSRFCookie,
|
|
Value: tok,
|
|
Path: "/",
|
|
HttpOnly: false, // read back only by the form on the same page; SameSite blocks cross-site
|
|
SameSite: http.SameSiteStrictMode,
|
|
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
|
MaxAge: int(claimCodeTTL.Seconds()),
|
|
})
|
|
return tok
|
|
}
|
|
|
|
func (s *Server) validClaimCSRF(r *http.Request) bool {
|
|
want := s.claimCSRFToken()
|
|
form := r.FormValue(csrfFormField)
|
|
if subtle.ConstantTimeCompare([]byte(form), []byte(want)) != 1 {
|
|
return false
|
|
}
|
|
c, err := r.Cookie(claimCSRFCookie)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
|
|
}
|
|
|
|
// ── the limiter (per-source IP + a global counter; both must be clear) ───────────────────────
|
|
|
|
func (s *Server) claimRateLocked() (locked bool, till time.Time) {
|
|
s.claimMu.Lock()
|
|
defer s.claimMu.Unlock()
|
|
now := s.claimNow()
|
|
if s.claimGlobal.lockedTill.After(now) {
|
|
return true, s.claimGlobal.lockedTill
|
|
}
|
|
return false, time.Time{}
|
|
}
|
|
|
|
func (s *Server) claimSourceLocked(ip string) (locked bool, till time.Time) {
|
|
s.claimMu.Lock()
|
|
defer s.claimMu.Unlock()
|
|
now := s.claimNow()
|
|
a := s.claimAttempts[ip]
|
|
if a != nil && a.lockedTill.After(now) {
|
|
return true, a.lockedTill
|
|
}
|
|
return false, time.Time{}
|
|
}
|
|
|
|
// claimRegisterFailure bumps the per-IP + global counters; on hitting the cap it locks that
|
|
// scope for claimLockoutWindow and returns locked=true (the caller reports the lockout event).
|
|
// An EXPIRED lock resets its scope's counter first, so a fresh attempt after the window starts
|
|
// clean rather than re-locking on a stale count.
|
|
func (s *Server) claimRegisterFailure(ip string) (locked bool) {
|
|
s.claimMu.Lock()
|
|
defer s.claimMu.Unlock()
|
|
now := s.claimNow()
|
|
if s.claimAttempts == nil {
|
|
s.claimAttempts = make(map[string]*claimAttempt)
|
|
}
|
|
a := s.claimAttempts[ip]
|
|
if a == nil {
|
|
a = &claimAttempt{}
|
|
s.claimAttempts[ip] = a
|
|
}
|
|
if !a.lockedTill.IsZero() && !a.lockedTill.After(now) {
|
|
*a = claimAttempt{} // per-IP lock expired → clean slate
|
|
}
|
|
if !s.claimGlobal.lockedTill.IsZero() && !s.claimGlobal.lockedTill.After(now) {
|
|
s.claimGlobal = claimAttempt{} // global lock expired → clean slate
|
|
}
|
|
a.count++
|
|
s.claimGlobal.count++
|
|
if a.count >= claimMaxAttempts {
|
|
a.lockedTill = now.Add(claimLockoutWindow)
|
|
locked = true
|
|
}
|
|
if s.claimGlobal.count >= claimMaxAttempts {
|
|
s.claimGlobal.lockedTill = now.Add(claimLockoutWindow)
|
|
locked = true
|
|
}
|
|
return locked
|
|
}
|
|
|
|
func (s *Server) claimClearFailures(ip string) {
|
|
s.claimMu.Lock()
|
|
defer s.claimMu.Unlock()
|
|
delete(s.claimAttempts, ip)
|
|
s.claimGlobal = claimAttempt{}
|
|
}
|
|
|
|
// claimNow is the clock seam (tests inject a fake). Defaults to time.Now.
|
|
func (s *Server) claimNow() time.Time {
|
|
if s.claimClock != nil {
|
|
return s.claimClock()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func requestIP(r *http.Request) string {
|
|
ip := r.RemoteAddr
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
ip = strings.Split(fwd, ",")[0]
|
|
}
|
|
return strings.TrimSpace(ip)
|
|
}
|
|
|
|
// ── the pages ────────────────────────────────────────────────────────────────────────────────
|
|
|
|
// claimPageAllowedPath reports the paths reachable while the unclaimed gate is active (the claim
|
|
// page itself, its static assets, health). Everything else is gated.
|
|
func claimPageAllowedPath(path string) bool {
|
|
switch path {
|
|
case "/claim", "/claim/request-new-code", "/api/health":
|
|
return true
|
|
}
|
|
return strings.HasPrefix(path, "/static/")
|
|
}
|
|
|
|
// serveClaimGate is invoked by RequireAuth when the unclaimed gate is active and the request is
|
|
// NOT an allowed path: render the claim page (HTML) or a 401 (API / mutating).
|
|
func (s *Server) serveClaimGate(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
fmt.Fprint(w, `{"ok":false,"error":"dashboard not yet claimed"}`)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/claim", http.StatusFound)
|
|
}
|
|
|
|
// handleClaimPage renders the claim/reset code-entry page (GET). Reachable pre-auth: the code is
|
|
// the strong factor. For a claimed box (password set) it doubles as the reset-code entry.
|
|
func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) {
|
|
csrf := s.setClaimCSRFCookie(w, r)
|
|
hash, _, _ := s.effectiveClaimCode()
|
|
reset := s.authEnabled() // a set password means this is the reset flow, not first-claim
|
|
data := map[string]interface{}{
|
|
"Title": "A szerver beállítása",
|
|
"CustomerName": s.cfg.Customer.Name,
|
|
"Domain": s.cfg.Customer.Domain,
|
|
"Version": s.version,
|
|
"Error": errorMsg,
|
|
"Flash": flashMsg,
|
|
"ClaimCSRF": csrf,
|
|
"IsReset": reset,
|
|
"HasCode": hash != "",
|
|
"MinPassword": claimMinPassword,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, "claim", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (claim): %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleClaimSubmit verifies the code and sets the customer's password (POST /claim). On success
|
|
// the box is claimed (or the password reset), the code generation is consumed (single-use), all
|
|
// sessions are invalidated and a fresh one is issued.
|
|
func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
if !s.validClaimCSRF(r) {
|
|
s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
|
|
return
|
|
}
|
|
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
|
|
ip := requestIP(r)
|
|
|
|
if locked, _ := s.claimRateLocked(); locked {
|
|
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
|
|
return
|
|
}
|
|
if locked, _ := s.claimSourceLocked(ip); locked {
|
|
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
|
|
return
|
|
}
|
|
|
|
code := strings.TrimSpace(r.FormValue("code"))
|
|
newPassword := r.FormValue("new_password")
|
|
confirm := r.FormValue("confirm_password")
|
|
|
|
hash, generation, issuedAt := s.effectiveClaimCode()
|
|
if hash == "" {
|
|
s.handleClaimPage(w, r, "Nincs aktív kód — kérj újat az alábbi gombbal.", "")
|
|
return
|
|
}
|
|
|
|
// Code checks: not expired, not an already-consumed generation, hash matches. A failure of
|
|
// ANY of these counts toward the lockout (they are indistinguishable to a guesser).
|
|
valid := true
|
|
if consumed := s.settings.GetClaimConsumedGeneration(); generation <= consumed {
|
|
valid = false // this code was already used (single-use)
|
|
}
|
|
if valid && issuedAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, issuedAt); err == nil && s.claimNow().Sub(t) > claimCodeTTL {
|
|
valid = false // expired
|
|
}
|
|
}
|
|
if valid && bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) != nil {
|
|
valid = false // wrong code
|
|
}
|
|
if !valid {
|
|
if s.claimRegisterFailure(ip) {
|
|
s.reportClaimLockout(ip)
|
|
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
|
|
return
|
|
}
|
|
s.handleClaimPage(w, r, "Hibás vagy lejárt kód", "")
|
|
return
|
|
}
|
|
|
|
// Password rules (min length, match).
|
|
if len(newPassword) < claimMinPassword {
|
|
s.handleClaimPage(w, r, fmt.Sprintf("A jelszónak legalább %d karakter hosszúnak kell lennie", claimMinPassword), "")
|
|
return
|
|
}
|
|
if newPassword != confirm {
|
|
s.handleClaimPage(w, r, "A két jelszó nem egyezik", "")
|
|
return
|
|
}
|
|
|
|
pwHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] claim: hashing new password: %v", err)
|
|
s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
|
|
return
|
|
}
|
|
if err := s.settings.SetPasswordHash(string(pwHash)); err != nil {
|
|
s.logger.Printf("[ERROR] [web] claim: saving password: %v", err)
|
|
s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
|
|
return
|
|
}
|
|
// Consume the generation (single-use) + mark claimed (set-only). Order: consume BEFORE
|
|
// claimed so a crash between them can't leave a reusable code on a claimed box.
|
|
if err := s.settings.SetClaimConsumedGeneration(generation); err != nil {
|
|
s.logger.Printf("[WARN] [web] claim: recording consumed generation failed: %v", err)
|
|
}
|
|
if err := s.settings.SetClaimed(); err != nil {
|
|
s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err)
|
|
}
|
|
s.claimClearFailures(ip)
|
|
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
|
|
|
|
action := "claimed"
|
|
if wasReset {
|
|
action = "password reset"
|
|
}
|
|
s.logger.Printf("[INFO] [web] dashboard %s by the customer from %s (code generation %d consumed)", action, ip, generation)
|
|
|
|
// Issue a fresh session so the customer lands logged-in.
|
|
token := s.createSession()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(sessionMaxAge.Seconds()),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
|
})
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
// handleClaimRequestNewCode forwards a "kérj új kódot" / "Elfelejtett jelszó" to the hub, which
|
|
// emails a FRESH code to the REGISTERED address only (the requester never chooses the
|
|
// destination). The response is always the neutral confirmation page.
|
|
func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
if !s.validClaimCSRF(r) {
|
|
s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
|
|
return
|
|
}
|
|
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
|
|
s.handleClaimPage(w, r, "", "Ha az e-mail cím regisztrálva van, elküldtük a kódot.")
|
|
}
|
|
|
|
// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
|
|
// v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response;
|
|
// it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the
|
|
// emailed code works the moment it lands instead of after the next ACK (~15 min). An old hub's
|
|
// bare {"status":"ok"} response is a clean no-op (no claim object → Reconcile skips).
|
|
func (s *Server) requestHubResetCode() {
|
|
if s.cfg.Hub.URL == "" || s.cfg.Hub.APIKey == "" {
|
|
s.logger.Printf("[WARN] [web] claim: cannot request a new code — hub URL/key not configured")
|
|
return
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"customer_id": s.cfg.Customer.ID})
|
|
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.cfg.Hub.URL, "/")+"/api/v1/claim/reset-request", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] claim: building reset-request: %v", err)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+s.cfg.Hub.APIKey)
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] claim: reset-request to hub failed: %v", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
s.logger.Printf("[WARN] [web] claim: hub reset-request returned HTTP %d", resp.StatusCode)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] claim: requested a fresh code from the hub for %s", s.cfg.Customer.ID)
|
|
var payload struct {
|
|
Claim *report.ClaimStatus `json:"claim"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&payload); err != nil {
|
|
s.logger.Printf("[WARN] [web] claim: parsing reset-request response failed (code arrives via the next ACK): %v", err)
|
|
return
|
|
}
|
|
if payload.Claim != nil && s.settings != nil {
|
|
sync := &report.ClaimSync{Settings: s.settings, Logger: s.logger}
|
|
sync.Reconcile(payload.Claim)
|
|
}
|
|
}
|
|
|
|
// PrintLocalResetCode is the root escape hatch (v0.122.0, --print-reset-code): generate a fresh
|
|
// local claim/reset code, install its hash at a generation ABOVE any cached/consumed one (so the
|
|
// gate accepts it), persist to settings.json, and print the plaintext ONCE to stdout. Same gate
|
|
// consumes it (single-use). Root-gated by reachability (docker exec into the container). Returns
|
|
// a process exit code.
|
|
func PrintLocalResetCode(sett ClaimHatchSettings, cfg ClaimHatchConfig) int {
|
|
code, err := localCode()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "print-reset-code: generating code: %v\n", err)
|
|
return 1
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(code), 10)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "print-reset-code: hashing code: %v\n", err)
|
|
return 1
|
|
}
|
|
_, cachedGen, _ := sett.GetClaimCode()
|
|
nextGen := cachedGen
|
|
if cfg.WebClaimGeneration() > nextGen {
|
|
nextGen = cfg.WebClaimGeneration()
|
|
}
|
|
if c := sett.GetClaimConsumedGeneration(); c >= nextGen {
|
|
nextGen = c
|
|
}
|
|
nextGen++ // strictly above cached, baked, and consumed → the gate treats it as fresh + unused
|
|
if err := sett.SetClaimCode(string(hash), nextGen, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("Egyszer használható helyi beállító/visszaállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code)
|
|
return 0
|
|
}
|
|
|
|
// ClaimHatchSettings / ClaimHatchConfig are the minimal seams the escape hatch needs (satisfied
|
|
// by *settings.Settings and *config.Config respectively — kept as interfaces so cmd/ wires them
|
|
// without this package importing config for a one-off).
|
|
type ClaimHatchSettings interface {
|
|
GetClaimCode() (hash string, generation int, issuedAt string)
|
|
GetClaimConsumedGeneration() int
|
|
SetClaimCode(hash string, generation int, issuedAt string) error
|
|
}
|
|
|
|
type ClaimHatchConfig interface {
|
|
WebClaimGeneration() int
|
|
}
|
|
|
|
// localCode makes a readable one-time code (three 4-char base32-ish groups) without needing the
|
|
// hub's Hungarian word list — it is typed once, locally, by the operator.
|
|
func localCode() (string, error) {
|
|
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" // no ambiguous 0/1/i/l/o
|
|
b := make([]byte, 12)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
out := make([]byte, 0, 14)
|
|
for i, v := range b {
|
|
if i > 0 && i%4 == 0 {
|
|
out = append(out, '-')
|
|
}
|
|
out = append(out, alphabet[int(v)%len(alphabet)])
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// reportClaimLockout pushes the allowlisted claim_lockout event (operator + customer visibility).
|
|
func (s *Server) reportClaimLockout(ip string) {
|
|
s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip)
|
|
if s.notifier != nil {
|
|
s.notifier.PushEvent("claim_lockout", "warning",
|
|
"Túl sok hibás beállító/visszaállító kód — a beállító oldal 15 percre zárolva",
|
|
map[string]interface{}{"source": ip})
|
|
}
|
|
}
|