hub: customer-claim password arc parts 1+2 — code engine, emails, ACK, configgen bake, UI (v0.50.0)
Closes DRILL-day0-vm F-4 hub-side: per-customer claim state (customer_claims,
bcrypt-only custody), the claim engine (issue at real config retrieve = Day-0
bake; first-report issue for live boxes; resend rotates generation; reset
rate-limited 3/day), three Hungarian emails via the dispatcher, report-ACK
claim object {code_hash, generation, issued_at} + set-only claimed ingest,
web.claim_code_* baked into generated controller.yaml, Setup-tab status chip
+ resend button, POST /api/v1/claim/reset-request (self-scoped), claim_lockout
event allowlisted. 13 new tests; full repo green.
Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package web
|
||||
|
||||
// Customer-claim arc (v0.50.0) — the Setup-tab claim card + the operator resend route.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type uiFakeMailer struct{ sends []string }
|
||||
|
||||
func (f *uiFakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error {
|
||||
f.sends = append(f.sends, kind)
|
||||
return nil
|
||||
}
|
||||
|
||||
func withUIClaim(t *testing.T, s *Server, st *store.Store) (*claim.Engine, *uiFakeMailer) {
|
||||
t.Helper()
|
||||
m := &uiFakeMailer{}
|
||||
e := &claim.Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)}
|
||||
s.SetClaimEngine(e)
|
||||
return e, m
|
||||
}
|
||||
|
||||
// The Setup tab renders the claim card in each state, and never any plaintext code (there is
|
||||
// none to render — the assertion pins that no template regression invents one).
|
||||
func TestCustomerPage_ClaimCardStates(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
e, _ := withUIClaim(t, s, st)
|
||||
cc := &store.CustomerConfig{
|
||||
CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu",
|
||||
RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu",
|
||||
}
|
||||
if err := st.SaveCustomerConfig(cc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// No claim row yet → neutral chip, no resend form.
|
||||
html := renderCustomerPage(t, s, "acme")
|
||||
if !strings.Contains(html, "no code issued yet") {
|
||||
t.Error("missing the no-code-issued chip")
|
||||
}
|
||||
if strings.Contains(html, "/configs/acme/claim-resend") {
|
||||
t.Error("resend form must not render before a code exists")
|
||||
}
|
||||
|
||||
// Issued (unclaimed) → Nyitott chip + resend form; the bcrypt hash must NOT leak into HTML.
|
||||
cs, err := e.EnsureIssued(cc)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
html = renderCustomerPage(t, s, "acme")
|
||||
if !strings.Contains(html, "Nyitott — kód kiküldve") {
|
||||
t.Error("missing the unclaimed-emailed chip")
|
||||
}
|
||||
if !strings.Contains(html, "/configs/acme/claim-resend") || !strings.Contains(html, "Kód újraküldése") {
|
||||
t.Error("missing the resend form/button")
|
||||
}
|
||||
if strings.Contains(html, cs.CodeHash) {
|
||||
t.Error("the code HASH leaked into the page (nothing claim-secret may render)")
|
||||
}
|
||||
|
||||
// Claimed → Claimed chip + the reset-flavored button label.
|
||||
if err := e.MarkClaimed(cc); err != nil {
|
||||
t.Fatalf("MarkClaimed: %v", err)
|
||||
}
|
||||
html = renderCustomerPage(t, s, "acme")
|
||||
if !strings.Contains(html, ">Claimed ") {
|
||||
t.Error("missing the Claimed chip")
|
||||
}
|
||||
if !strings.Contains(html, "Visszaállító kód küldése") {
|
||||
t.Error("claimed state should offer the reset-code button")
|
||||
}
|
||||
}
|
||||
|
||||
// The resend route rotates the code (generation++) and redirects with a flash.
|
||||
func TestClaimResendRoute_RotatesAndRedirects(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
e, m := withUIClaim(t, s, st)
|
||||
cc := &store.CustomerConfig{
|
||||
CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu",
|
||||
RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu",
|
||||
}
|
||||
st.SaveCustomerConfig(cc)
|
||||
if _, err := e.EnsureIssued(cc); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
before, _ := st.GetClaim("acme")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleClaimResend(rr, httptest.NewRequest("POST", "/configs/acme/claim-resend", nil), "acme")
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("resend status = %d, want 303", rr.Code)
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); !strings.Contains(loc, "flash=claim-resent") {
|
||||
t.Fatalf("resend redirect = %q", loc)
|
||||
}
|
||||
after, _ := st.GetClaim("acme")
|
||||
if after.Generation != before.Generation+1 {
|
||||
t.Fatalf("generation %d → %d, want +1", before.Generation, after.Generation)
|
||||
}
|
||||
if after.CodeHash == before.CodeHash {
|
||||
t.Fatal("resend did not rotate the hash")
|
||||
}
|
||||
if len(m.sends) != 2 {
|
||||
t.Fatalf("sends = %v, want issue+resend", m.sends)
|
||||
}
|
||||
// Sanity: the stored value is a bcrypt hash (never a plaintext code).
|
||||
if bcrypt.CompareHashAndPassword([]byte(after.CodeHash), []byte("definitely-wrong")) == nil {
|
||||
t.Fatal("stored hash verified a wrong code?!")
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
// the same configFormData the standalone chrome renders. Zero-valued (and never rendered)
|
||||
// when the customer has no config.
|
||||
ConfigForm configFormView
|
||||
|
||||
// Claim (v0.50.0, customer-claim arc): the dashboard claim state for the Setup-tab card —
|
||||
// nil when no code has been issued yet (pre-arc / never-pulled customer).
|
||||
Claim *store.ClaimState
|
||||
}
|
||||
|
||||
pendingSet := make(map[string]bool, len(pendingTails))
|
||||
@@ -427,6 +431,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
data.ConfigForm = s.configFormData(r, false, cfg, nil, "")
|
||||
}
|
||||
|
||||
// Claim state (v0.50.0) for the Setup-tab access card (nil-safe: no row → no card content).
|
||||
if cs, err := s.store.GetClaim(customerID); err == nil {
|
||||
data.Claim = cs
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil {
|
||||
s.logger.Printf("[ERROR] Template render: %v", err)
|
||||
@@ -615,6 +624,29 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleClaimResend (v0.50.0, customer-claim arc) rotates the claim/reset code and re-sends it to
|
||||
// the REGISTERED customer address — the operator "Kód újraküldése" / "Visszaállító kód küldése"
|
||||
// button. The old code stops verifying immediately (single active code); the fresh hash reaches
|
||||
// the box on its next report ACK (no config bump needed). No plaintext is ever rendered or logged.
|
||||
func (s *Server) handleClaimResend(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if s.claimEngine == nil {
|
||||
http.Error(w, "Claim engine 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
|
||||
}
|
||||
if err := s.claimEngine.Resend(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] claim resend for %s: %v", customerID, err)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resend-failed#tab=setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] claim code re-sent for %s (operator resend; generation rotated)", customerID)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resent#tab=setup", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password —
|
||||
// the explicit operator recovery for a consumed-password dead-end (fresh-guest DR, consumed-but-failed
|
||||
// install). Scoped to the resource labelled for THIS customer (the provisioner refuses unless exactly one).
|
||||
@@ -724,7 +756,10 @@ func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, cus
|
||||
templateYAML = s.templateFetcher.Template()
|
||||
}
|
||||
|
||||
yamlOutput, err := configgen.Generate(templateYAML, cfg)
|
||||
// Claim arc (v0.50.0): the preview BAKES an existing claim hash (so it matches what a box
|
||||
// would pull) but never ISSUES one — issuing + emailing belongs to the real config retrieve.
|
||||
claimState, _ := s.store.GetClaim(customerID)
|
||||
yamlOutput, err := configgen.Generate(templateYAML, cfg, claimState)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err)
|
||||
http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
@@ -60,6 +61,7 @@ type Server struct {
|
||||
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
|
||||
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
||||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
||||
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
|
||||
|
||||
sessions map[string]*hubSession
|
||||
sessionsMu sync.RWMutex
|
||||
@@ -149,6 +151,9 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
|
||||
// offsite enabled returns an error (offsite not configured on this hub).
|
||||
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
|
||||
|
||||
// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0).
|
||||
func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e }
|
||||
|
||||
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
|
||||
// degrades to manual text entry.
|
||||
func (s *Server) SetGiteaClient(c *gitea.Client) {
|
||||
@@ -418,6 +423,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/claim-resend"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/claim-resend")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleClaimResend(w, r, customerID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
|
||||
|
||||
@@ -421,6 +421,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Dashboard access — customer claim</h2>
|
||||
<div class="credential-row">
|
||||
<div>
|
||||
<span class="label">Claim status</span>
|
||||
<div style="margin: 0.3rem 0;">
|
||||
{{if .Claim}}
|
||||
{{if .Claim.ClaimedAt}}
|
||||
<span class="badge badge-ok">Claimed {{timeAgoPtr .Claim.ClaimedAt}}</span>
|
||||
{{else if .Claim.EmailedAt}}
|
||||
<span class="badge badge-warn">Nyitott — kód kiküldve {{timeAgoPtr .Claim.EmailedAt}}</span>
|
||||
{{else}}
|
||||
<span class="badge badge-error">Nyitott — a kód e-mail NEM ment ki (resend!)</span>
|
||||
{{end}}
|
||||
<span class="form-hint" style="margin-left: 0.5rem;">generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}}</span>
|
||||
{{else}}
|
||||
<span class="badge badge-neutral">no code issued yet</span>
|
||||
<span class="form-hint" style="margin-left: 0.5rem;">issued automatically at the first config pull or report</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<span class="form-hint">The customer sets + owns the dashboard password (claim code → own password). The code goes ONLY to the registered address ({{.Email}}); the hub stores a hash — no plaintext code exists to display.</span>
|
||||
</div>
|
||||
{{if .Claim}}
|
||||
<form method="POST" action="/configs/{{.CustomerID}}/claim-resend" style="margin-top: 0.5rem;"
|
||||
onsubmit="return confirm('Send a fresh code to the registered address? The previous code stops working immediately.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline btn-sm">{{if .Claim.ClaimedAt}}Visszaállító kód küldése{{else}}Kód újraküldése{{end}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Setup Command</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
||||
|
||||
Reference in New Issue
Block a user