592818492c
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
95 lines
4.4 KiB
Go
95 lines
4.4 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
|
)
|
|
|
|
// selfbind_mint.go — the OPERATOR side of customer self-bind (v0.66.0, R-27 slice 1): the "Send
|
|
// self-bind link" button on the customer page mints a 7-day capability token and emails the customer
|
|
// a public bind link. The customer side (the public /bind/ page) lives in selfbind.go. No hub
|
|
// customer-login exists — the emailed link IS the auth model.
|
|
|
|
// selfBindTTL is the capability link's lifetime. After it, self-bind falls back to operator-bind
|
|
// unchanged (the token simply reads as expired; nothing else regresses).
|
|
const selfBindTTL = 7 * 24 * time.Hour
|
|
|
|
// selfBindBaseURL is the hub's public origin, matching the hardcoded origin used elsewhere
|
|
// (configgen). The link is https://hub.felhom.eu/bind/<token>.
|
|
const selfBindBaseURL = "https://hub.felhom.eu"
|
|
|
|
// SelfBindMailer delivers the self-bind capability link to the registered customer address. The
|
|
// notify.Dispatcher implements it (a sibling of the claim mailer — NOT routed through claim). The
|
|
// signature takes plain strings so the implementation needs no import of this package.
|
|
type SelfBindMailer interface {
|
|
SendSelfBindEmail(customerID, email, link string) error
|
|
}
|
|
|
|
// SetSelfBindMailer wires the self-bind link sender (v0.66.0). Absent → the button returns 502.
|
|
func (s *Server) SetSelfBindMailer(m SelfBindMailer) { s.selfBindMailer = m }
|
|
|
|
func selfBindHash(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// handleSelfBindLinkSend — POST /customers/{id}/selfbind-link. Mints a single-active capability token
|
|
// for the customer and emails the public bind link. Honesty rules:
|
|
// - F1: no registered email → nothing is minted, LOUD flash (a link no one can receive is useless).
|
|
// - F2: email send fails → the just-minted token is deleted (not left silently live), LOUD flash.
|
|
//
|
|
// The plaintext token exists only between minting and the send; it is never logged (only an 8-char
|
|
// hash prefix) and never persisted (only its sha256).
|
|
func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request, customerID string) {
|
|
if s.selfBindMailer == nil {
|
|
http.Error(w, "Self-bind mailer is not configured on this hub", http.StatusBadGateway)
|
|
return
|
|
}
|
|
cfg, err := s.store.GetCustomerConfig(customerID)
|
|
if err != nil || cfg == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// F1: refuse to mint a link that cannot be delivered.
|
|
if cfg.Email == "" {
|
|
s.logger.Printf("[WARN] self-bind link for %s NOT sent: customer has no registered email", customerID)
|
|
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-no-email#tab=setup", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
token, err := configgen.RandomHex(32) // 256-bit capability token
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] self-bind link for %s: token generation: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
hash := selfBindHash(token)
|
|
if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil {
|
|
s.logger.Printf("[ERROR] self-bind link for %s: minting token: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
link := selfBindBaseURL + "/bind/" + token
|
|
if err := s.selfBindMailer.SendSelfBindEmail(customerID, cfg.Email, link); err != nil {
|
|
// F2: delivery failed — do not leave a live capability token behind (the plaintext link is
|
|
// already gone from memory, so nobody could ever use it; delete it and surface the failure).
|
|
if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil {
|
|
s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr)
|
|
} else {
|
|
s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err)
|
|
}
|
|
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-send-failed#tab=setup", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := s.store.MarkSelfBindEmailed(hash); err != nil {
|
|
s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err)
|
|
}
|
|
s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID)
|
|
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther)
|
|
}
|