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,179 @@
|
||||
// Package claim is the customer-claim password-code engine (v0.50.0, closes DRILL-day0-vm F-4).
|
||||
// The hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED customer
|
||||
// address, and stores only bcrypt(code) — the plaintext exists solely inside the email send
|
||||
// (the retrieval-passphrase custody rule). The controller receives the hash (+ generation)
|
||||
// baked into controller.yaml at Day-0 and via the report ACK for live boxes, and gates its
|
||||
// dashboard until the customer claims it by setting their own password. Reset rides the same
|
||||
// engine: a rotation bumps the generation (single active code) and NEVER clears claimed_at.
|
||||
package claim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) —
|
||||
// dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing
|
||||
// infeasible.
|
||||
const codeWords = 3
|
||||
|
||||
// maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer).
|
||||
const maxResetPerDay = 3
|
||||
|
||||
// EmailKind selects the Hungarian template for a code delivery.
|
||||
type EmailKind string
|
||||
|
||||
const (
|
||||
EmailClaim EmailKind = "claim" // first setup: "Elindult a Felhom szervered"
|
||||
EmailReset EmailKind = "reset" // forgotten password
|
||||
EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code)
|
||||
)
|
||||
|
||||
// Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or
|
||||
// logged by implementations (empty for EmailClaimed). kind is one of the EmailKind constants
|
||||
// (plain string in the signature so implementations need no import of this package).
|
||||
type Mailer interface {
|
||||
SendClaimEmail(kind, customerID, email, domain, code string) error
|
||||
}
|
||||
|
||||
// Engine composes the store, the code generator and the mailer. All methods are idempotent or
|
||||
// explicitly rotating; none ever stores or logs a plaintext code.
|
||||
type Engine struct {
|
||||
Store *store.Store
|
||||
Mailer Mailer
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func (e *Engine) logf(f string, a ...any) {
|
||||
if e.Logger != nil {
|
||||
e.Logger.Printf(f, a...)
|
||||
}
|
||||
}
|
||||
|
||||
// newCode generates a fresh code and its bcrypt hash.
|
||||
func newCode() (code, hash string, err error) {
|
||||
code, err = configgen.RandomPassphrase(codeWords)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("claim: generating code: %w", err)
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(code), 10)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("claim: hashing code: %w", err)
|
||||
}
|
||||
return code, string(h), nil
|
||||
}
|
||||
|
||||
// rotateAndSend rotates the code (generation+1 / create) and emails it. The email failure path
|
||||
// keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset
|
||||
// on the customer page and can resend. Returns the new generation.
|
||||
func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) {
|
||||
code, hash, err := newCode()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
gen, err := e.Store.RotateClaimCode(cc.CustomerID, hash)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("claim: storing code hash: %w", err)
|
||||
}
|
||||
if cc.Email == "" {
|
||||
e.logf("[ERROR] [claim] %s code generated (gen %d) but customer %s has NO registered email — deliver via resend after setting one", kind, gen, cc.CustomerID)
|
||||
return gen, fmt.Errorf("claim: customer %s has no registered email", cc.CustomerID)
|
||||
}
|
||||
if err := e.Mailer.SendClaimEmail(string(kind), cc.CustomerID, cc.Email, cc.Domain, code); err != nil {
|
||||
e.logf("[ERROR] [claim] %s code email to customer %s FAILED (gate stays armed; resend from the customer page): %v", kind, cc.CustomerID, err)
|
||||
return gen, fmt.Errorf("claim: sending %s email: %w", kind, err)
|
||||
}
|
||||
if err := e.Store.MarkClaimEmailed(cc.CustomerID); err != nil {
|
||||
e.logf("[WARN] [claim] %s code sent to customer %s but emailed_at not recorded: %v", kind, cc.CustomerID, err)
|
||||
}
|
||||
e.logf("[INFO] [claim] %s code (gen %d) emailed to the registered address of %s", kind, gen, cc.CustomerID)
|
||||
return gen, nil
|
||||
}
|
||||
|
||||
// EnsureIssued guarantees a claim row exists for the customer, issuing + emailing the first code
|
||||
// when absent. An existing row (any state) is returned untouched — repeated config pulls and
|
||||
// reports never rotate or re-send. This is the Day-0 entry point (API config retrieve) and the
|
||||
// live-box entry point (first report of an upgrading fleet).
|
||||
func (e *Engine) EnsureIssued(cc *store.CustomerConfig) (*store.ClaimState, error) {
|
||||
cs, err := e.Store.GetClaim(cc.CustomerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim: reading state: %w", err)
|
||||
}
|
||||
if cs != nil {
|
||||
return cs, nil
|
||||
}
|
||||
if _, err := e.rotateAndSend(cc, EmailClaim); err != nil {
|
||||
// The hash (if stored) still arms the gate; surface the error for the caller's log.
|
||||
cs, gerr := e.Store.GetClaim(cc.CustomerID)
|
||||
if gerr == nil && cs != nil {
|
||||
return cs, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return e.Store.GetClaim(cc.CustomerID)
|
||||
}
|
||||
|
||||
// Resend rotates the code and re-sends it — the operator "Kód újraküldése" button. An unclaimed
|
||||
// customer gets the claim template; a claimed one gets the reset template (the only code a
|
||||
// claimed box can consume is a reset).
|
||||
func (e *Engine) Resend(cc *store.CustomerConfig) error {
|
||||
cs, err := e.Store.GetClaim(cc.CustomerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: reading state: %w", err)
|
||||
}
|
||||
kind := EmailClaim
|
||||
if cs.Claimed() {
|
||||
kind = EmailReset
|
||||
}
|
||||
_, err = e.rotateAndSend(cc, kind)
|
||||
return err
|
||||
}
|
||||
|
||||
// RequestReset handles a controller-forwarded "Elfelejtett jelszó": rate-limited hub-side
|
||||
// (maxResetPerDay per customer), then rotates + emails a reset code. The neutral customer-facing
|
||||
// response ("ha az e-mail cím regisztrálva van…") is the CALLER's job — this returns real errors
|
||||
// for the operator log.
|
||||
func (e *Engine) RequestReset(cc *store.CustomerConfig) error {
|
||||
cs, err := e.Store.GetClaim(cc.CustomerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: reading state: %w", err)
|
||||
}
|
||||
if cs == nil {
|
||||
// No claim row yet (pre-arc box asking for a reset): issue the first code instead.
|
||||
_, err := e.EnsureIssued(cc)
|
||||
return err
|
||||
}
|
||||
count, err := e.Store.BumpResetCount(cc.CustomerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: reset limiter: %w", err)
|
||||
}
|
||||
if count > maxResetPerDay {
|
||||
e.logf("[WARN] [claim] reset request for %s REFUSED: daily cap reached (%d/%d)", cc.CustomerID, count, maxResetPerDay)
|
||||
return fmt.Errorf("claim: reset cap reached for %s (%d/day)", cc.CustomerID, maxResetPerDay)
|
||||
}
|
||||
_, err = e.rotateAndSend(cc, EmailReset)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
|
||||
// email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops).
|
||||
func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error {
|
||||
transitioned, err := e.Store.MarkClaimed(cc.CustomerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: marking claimed: %w", err)
|
||||
}
|
||||
if !transitioned {
|
||||
return nil
|
||||
}
|
||||
e.logf("[INFO] [claim] customer %s CLAIMED its dashboard (password set by the customer)", cc.CustomerID)
|
||||
if cc.Email != "" {
|
||||
if err := e.Mailer.SendClaimEmail(string(EmailClaimed), cc.CustomerID, cc.Email, cc.Domain, ""); err != nil {
|
||||
e.logf("[WARN] [claim] claimed-confirmation email to %s failed: %v", cc.CustomerID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package claim
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// fakeMailer records sends and captures the LAST code (test-only — the engine itself never
|
||||
// retains one). failNext makes the next send fail.
|
||||
type fakeMailer struct {
|
||||
sends []string // "kind:customerID:email"
|
||||
lastCode string
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (f *fakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error {
|
||||
if f.failNext {
|
||||
f.failNext = false
|
||||
return errSend
|
||||
}
|
||||
f.sends = append(f.sends, kind+":"+customerID+":"+email)
|
||||
f.lastCode = code
|
||||
return nil
|
||||
}
|
||||
|
||||
var errSend = &sendErr{}
|
||||
|
||||
type sendErr struct{}
|
||||
|
||||
func (*sendErr) Error() string { return "send failed" }
|
||||
|
||||
func newTestEngine(t *testing.T) (*Engine, *store.Store, *fakeMailer) {
|
||||
t.Helper()
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
m := &fakeMailer{}
|
||||
return &Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)}, st, m
|
||||
}
|
||||
|
||||
func cust() *store.CustomerConfig {
|
||||
return &store.CustomerConfig{CustomerID: "c1", Email: "owner@example.hu", Domain: "example.hu"}
|
||||
}
|
||||
|
||||
// EnsureIssued creates the row + emails ONCE; repeated calls neither rotate nor re-send.
|
||||
func TestEnsureIssued_IdempotentSingleEmail(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
cs, err := e.EnsureIssued(cust())
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
if cs == nil || cs.Generation != 1 || cs.CodeHash == "" {
|
||||
t.Fatalf("first issue: got %+v", cs)
|
||||
}
|
||||
if len(m.sends) != 1 || !strings.HasPrefix(m.sends[0], "claim:c1:") {
|
||||
t.Fatalf("expected exactly one claim email, got %v", m.sends)
|
||||
}
|
||||
// The stored hash must verify the emailed code and must NOT contain it (bcrypt-only custody).
|
||||
if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil {
|
||||
t.Fatal("stored hash does not verify the emailed code")
|
||||
}
|
||||
if strings.Contains(cs.CodeHash, m.lastCode) {
|
||||
t.Fatal("plaintext code leaked into the stored hash")
|
||||
}
|
||||
|
||||
cs2, err := e.EnsureIssued(cust())
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureIssued (second): %v", err)
|
||||
}
|
||||
if cs2.Generation != 1 || cs2.CodeHash != cs.CodeHash {
|
||||
t.Fatalf("second EnsureIssued must not rotate: gen %d hash-changed=%v", cs2.Generation, cs2.CodeHash != cs.CodeHash)
|
||||
}
|
||||
if len(m.sends) != 1 {
|
||||
t.Fatalf("second EnsureIssued must not re-send: %v", m.sends)
|
||||
}
|
||||
// §10 bcrypt-only storage: the DB row itself never contains the plaintext.
|
||||
row, err := st.GetClaim("c1")
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("GetClaim: %v %v", row, err)
|
||||
}
|
||||
if strings.Contains(row.CodeHash, m.lastCode) || row.CodeHash == m.lastCode {
|
||||
t.Fatal("DB row contains the plaintext code")
|
||||
}
|
||||
}
|
||||
|
||||
// Resend rotates: generation bumps, the OLD code no longer verifies against the stored hash
|
||||
// (single active code). Red-proof partner: drop the generation bump in RotateClaimCode → fails.
|
||||
func TestResend_RotatesAndInvalidatesOldCode(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
if _, err := e.EnsureIssued(cust()); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
oldCode := m.lastCode
|
||||
if err := e.Resend(cust()); err != nil {
|
||||
t.Fatalf("Resend: %v", err)
|
||||
}
|
||||
cs, _ := st.GetClaim("c1")
|
||||
if cs.Generation != 2 {
|
||||
t.Fatalf("generation after resend = %d, want 2", cs.Generation)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(oldCode)) == nil {
|
||||
t.Fatal("OLD code still verifies after resend — single-active-code broken")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil {
|
||||
t.Fatal("new code does not verify after resend")
|
||||
}
|
||||
if len(m.sends) != 2 || !strings.HasPrefix(m.sends[1], "claim:") {
|
||||
t.Fatalf("unclaimed resend should use the claim template: %v", m.sends)
|
||||
}
|
||||
}
|
||||
|
||||
// A claimed customer's resend uses the RESET template and never clears claimed_at.
|
||||
func TestResend_ClaimedGetsResetTemplateAndStaysClaimed(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
if _, err := e.EnsureIssued(cust()); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
if err := e.MarkClaimed(cust()); err != nil {
|
||||
t.Fatalf("MarkClaimed: %v", err)
|
||||
}
|
||||
if err := e.Resend(cust()); err != nil {
|
||||
t.Fatalf("Resend: %v", err)
|
||||
}
|
||||
cs, _ := st.GetClaim("c1")
|
||||
if !cs.Claimed() {
|
||||
t.Fatal("rotation cleared claimed_at — resets must never un-claim")
|
||||
}
|
||||
last := m.sends[len(m.sends)-1]
|
||||
if !strings.HasPrefix(last, "reset:") {
|
||||
t.Fatalf("claimed resend should use the reset template, got %s", last)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestReset caps at 3/day per customer, hub-side.
|
||||
func TestRequestReset_DailyCap(t *testing.T) {
|
||||
e, _, m := newTestEngine(t)
|
||||
if _, err := e.EnsureIssued(cust()); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := e.RequestReset(cust()); err != nil {
|
||||
t.Fatalf("RequestReset %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
sendsBefore := len(m.sends)
|
||||
if err := e.RequestReset(cust()); err == nil {
|
||||
t.Fatal("4th reset request of the day should be refused")
|
||||
}
|
||||
if len(m.sends) != sendsBefore {
|
||||
t.Fatal("refused reset must not send an email")
|
||||
}
|
||||
}
|
||||
|
||||
// MarkClaimed transitions once: confirmation email exactly once, idempotent afterwards.
|
||||
func TestMarkClaimed_TransitionOnce(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
if _, err := e.EnsureIssued(cust()); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
if err := e.MarkClaimed(cust()); err != nil {
|
||||
t.Fatalf("MarkClaimed: %v", err)
|
||||
}
|
||||
if err := e.MarkClaimed(cust()); err != nil {
|
||||
t.Fatalf("MarkClaimed (repeat): %v", err)
|
||||
}
|
||||
confirms := 0
|
||||
for _, s := range m.sends {
|
||||
if strings.HasPrefix(s, "claimed:") {
|
||||
confirms++
|
||||
}
|
||||
}
|
||||
if confirms != 1 {
|
||||
t.Fatalf("claimed-confirmation emails = %d, want exactly 1", confirms)
|
||||
}
|
||||
cs, _ := st.GetClaim("c1")
|
||||
if !cs.Claimed() {
|
||||
t.Fatal("not claimed after MarkClaimed")
|
||||
}
|
||||
}
|
||||
|
||||
// An email send failure keeps the rotated hash (gate stays armed) and surfaces the error.
|
||||
func TestIssue_EmailFailureKeepsGateArmed(t *testing.T) {
|
||||
e, st, m := newTestEngine(t)
|
||||
m.failNext = true
|
||||
cs, err := e.EnsureIssued(cust())
|
||||
if err == nil {
|
||||
t.Fatal("EnsureIssued should surface the send failure")
|
||||
}
|
||||
if cs == nil || cs.CodeHash == "" {
|
||||
t.Fatal("hash must be stored (gate armed) even when the email failed")
|
||||
}
|
||||
row, _ := st.GetClaim("c1")
|
||||
if row == nil || row.EmailedAt != nil {
|
||||
t.Fatalf("emailed_at must stay unset on failure: %+v", row)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user