From 3df95eac9048e589564e786acd419dd3d9d3ed9e Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 26 Jun 2026 19:49:54 +0200 Subject: [PATCH] more customer --- cmd/checkout/pool-server.go | 16 +- cmd/profile/main.go | 32 +- internal/customerauth/customerauth_test.go | 95 ++++++ internal/customerauth/password.go | 76 +++++ internal/customerauth/server.go | 378 +++++++++++++++++++++ internal/customerauth/session.go | 123 +++++++ internal/customerauth/store.go | 119 +++++++ pkg/actor/simple_grain_pool.go | 9 +- 8 files changed, 836 insertions(+), 12 deletions(-) create mode 100644 internal/customerauth/customerauth_test.go create mode 100644 internal/customerauth/password.go create mode 100644 internal/customerauth/server.go create mode 100644 internal/customerauth/session.go create mode 100644 internal/customerauth/store.go diff --git a/cmd/checkout/pool-server.go b/cmd/checkout/pool-server.go index 5e9d15b..62f4f88 100644 --- a/cmd/checkout/pool-server.go +++ b/cmd/checkout/pool-server.go @@ -239,15 +239,13 @@ func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http return } - // Create checkout with same ID as cart - var checkoutId checkout.CheckoutId = cart.MustNewCartId() - cookie, err := r.Cookie(checkoutCookieName) - if err == nil { - parsed, ok := cart.ParseCartId(cookie.Value) - if ok { - checkoutId = parsed - } - } + // The checkout grain is 1:1 with the cart it checks out (CheckoutId == + // CartId). Keying it by the cart id — not a lingering checkoutid cookie — + // means a fresh cart always gets a fresh checkout, so the Klarna order + // reflects the cart shown at /kassa. Reusing the cookie made a new cart's + // checkout latch onto a previous cart's frozen snapshot (InitializeCheckout + // silently refuses to re-snapshot a grain that already has a CartState). + checkoutId := checkout.CheckoutId(cartId) // Initialize checkout with cart state wrapped in Any cartStateAny := &messages.InitializeCheckout{ diff --git a/cmd/profile/main.go b/cmd/profile/main.go index d06f8e1..1b756c4 100644 --- a/cmd/profile/main.go +++ b/cmd/profile/main.go @@ -2,20 +2,39 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "log" "net" "net/http" "os" "os/signal" + "path/filepath" "time" + "git.k6n.net/mats/go-cart-actor/internal/customerauth" "git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/profile" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) +// authSecret returns the HMAC key for customer session cookies. It reads +// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and +// warns that issued sessions will not survive a restart (fine for dev). +func authSecret() []byte { + if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" { + return []byte(v) + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + log.Fatalf("could not generate auth secret: %v", err) + } + log.Print("warning: CUSTOMER_AUTH_SECRET unset — using an ephemeral key; customer sessions will not survive a restart") + return []byte(hex.EncodeToString(b)) +} + var podIp = os.Getenv("POD_IP") var name = os.Getenv("POD_NAME") @@ -64,6 +83,9 @@ func main() { err := diskStorage.LoadEvents(ctx, id, ret) return ret, err }, + // Destroy is an optional grain-eviction cleanup hook; profiles need none + // (disk storage persists via its own loop), so it's left unset — purge() + // skips a nil Destroy. TTL: 10 * time.Minute, PoolSize: 65535, Hostname: podIp, @@ -96,6 +118,14 @@ func main() { // passes the full request path without stripping the prefix. mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP)) + // Customer auth: password signup/login + session cookies + identity linking. + credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json")) + if err != nil { + log.Fatalf("Error loading credential store: %v\n", err) + } + authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler() + mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { grainCount, capacity := pool.LocalUsage() if grainCount >= capacity { @@ -126,7 +156,7 @@ func main() { }) srv := &http.Server{ - Addr: ":8080", + Addr: getEnv("PROFILE_ADDR", ":8080"), BaseContext: func(net.Listener) context.Context { return ctx }, ReadTimeout: 10 * time.Second, WriteTimeout: 20 * time.Second, diff --git a/internal/customerauth/customerauth_test.go b/internal/customerauth/customerauth_test.go new file mode 100644 index 0000000..76b90f4 --- /dev/null +++ b/internal/customerauth/customerauth_test.go @@ -0,0 +1,95 @@ +package customerauth + +import ( + "path/filepath" + "testing" + "time" +) + +func TestPasswordRoundTrip(t *testing.T) { + h, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hash: %v", err) + } + if !VerifyPassword("correct horse battery staple", h) { + t.Fatal("correct password did not verify") + } + if VerifyPassword("wrong", h) { + t.Fatal("wrong password verified") + } +} + +func TestPasswordHashesAreSalted(t *testing.T) { + a, _ := HashPassword("hunter2hunter2") + b, _ := HashPassword("hunter2hunter2") + if a == b { + t.Fatal("two hashes of the same password are identical — not salted") + } +} + +func TestVerifyRejectsMalformed(t *testing.T) { + for _, bad := range []string{"", "x", "pbkdf2-sha256$abc$salt$hash", "md5$1$a$b"} { + if VerifyPassword("whatever", bad) { + t.Fatalf("malformed hash %q verified", bad) + } + } +} + +func TestSessionRoundTrip(t *testing.T) { + s := NewSigner([]byte("test-secret")) + tok := s.Issue(12345, time.Hour) + id, err := s.Parse(tok) + if err != nil { + t.Fatalf("parse: %v", err) + } + if id != 12345 { + t.Fatalf("got id %d, want 12345", id) + } +} + +func TestSessionExpired(t *testing.T) { + s := NewSigner([]byte("test-secret")) + tok := s.Issue(1, -time.Second) + if _, err := s.Parse(tok); err != ErrExpiredToken { + t.Fatalf("got %v, want ErrExpiredToken", err) + } +} + +func TestSessionTamperAndWrongKey(t *testing.T) { + s := NewSigner([]byte("test-secret")) + tok := s.Issue(7, time.Hour) + if _, err := s.Parse(tok + "x"); err != ErrInvalidToken { + t.Fatalf("tampered token: got %v, want ErrInvalidToken", err) + } + other := NewSigner([]byte("different-secret")) + if _, err := other.Parse(tok); err != ErrInvalidToken { + t.Fatalf("wrong key: got %v, want ErrInvalidToken", err) + } +} + +func TestStoreRegisterDuplicateAndReload(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + store, err := LoadCredentialStore(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil { + t.Fatalf("register: %v", err) + } + // Duplicate (case-insensitive) is rejected. + if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists { + t.Fatalf("duplicate: got %v, want ErrEmailExists", err) + } + // Reload from disk and confirm the record persisted. + reloaded, err := LoadCredentialStore(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + rec, ok := reloaded.Get("USER@EXAMPLE.COM") + if !ok { + t.Fatal("record not found after reload") + } + if rec.ProfileID != 42 || rec.Hash != "hash1" { + t.Fatalf("unexpected record: %+v", rec) + } +} diff --git a/internal/customerauth/password.go b/internal/customerauth/password.go new file mode 100644 index 0000000..09130f8 --- /dev/null +++ b/internal/customerauth/password.go @@ -0,0 +1,76 @@ +// Package customerauth adds password-based signup/login to the profile service. +// +// It deliberately uses only the standard library: PBKDF2-HMAC-SHA256 for +// password hashing (crypto/pbkdf2, Go 1.24+) and HMAC-SHA256-signed cookies for +// sessions (crypto/hmac). No password ever leaves the server in plaintext and +// the session token is opaque + tamper-evident. +package customerauth + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "strconv" + "strings" +) + +// pbkdf2Iterations is the work factor for PBKDF2-HMAC-SHA256. 210_000 matches +// the current OWASP recommendation for PBKDF2-SHA256. +const pbkdf2Iterations = 210_000 + +const ( + saltLen = 16 + keyLen = 32 +) + +// HashPassword returns an encoded PBKDF2 hash of the form +// +// pbkdf2-sha256$$$ +// +// with a fresh random salt. The encoded string is self-describing so Verify can +// read the parameters back without external configuration. +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("customerauth: read salt: %w", err) + } + dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, keyLen) + if err != nil { + return "", fmt.Errorf("customerauth: pbkdf2: %w", err) + } + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", + pbkdf2Iterations, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(dk), + ), nil +} + +// VerifyPassword reports whether password matches the encoded hash. It is +// constant-time in the hash comparison and returns false for any malformed +// encoding rather than erroring, so callers can treat it as a simple predicate. +func VerifyPassword(password, encoded string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false + } + iter, err := strconv.Atoi(parts[1]) + if err != nil || iter <= 0 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 +} diff --git a/internal/customerauth/server.go b/internal/customerauth/server.go new file mode 100644 index 0000000..d2e5414 --- /dev/null +++ b/internal/customerauth/server.go @@ -0,0 +1,378 @@ +package customerauth + +import ( + "context" + "encoding/json" + "net/http" + "net/mail" + "strings" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/profile" + messages "git.k6n.net/mats/go-cart-actor/proto/profile" + "google.golang.org/protobuf/proto" +) + +// minPasswordLen is the minimum accepted password length at registration. +const minPasswordLen = 8 + +// ProfileApplier is the subset of the profile grain pool the auth server needs. +// The pool (and the UCP adapter's ProfileApplier) already satisfies it. +type ProfileApplier interface { + Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) +} + +// Server exposes password signup/login and identity-linking over HTTP, backed +// by the credential store (email→id+hash) and the profile grain pool. +type Server struct { + store *CredentialStore + applier ProfileApplier + signer *Signer + ttl time.Duration +} + +// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL. +func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server { + if ttl <= 0 { + ttl = DefaultSessionTTL + } + return &Server{store: store, applier: applier, signer: signer, ttl: ttl} +} + +// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth +// (the prefix is stripped by the caller). +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /register", s.handleRegister) + mux.HandleFunc("POST /login", s.handleLogin) + mux.HandleFunc("POST /logout", s.handleLogout) + mux.HandleFunc("GET /me", s.handleMe) + mux.HandleFunc("POST /link-cart", s.handleLinkCart) + mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout) + mux.HandleFunc("POST /link-order", s.handleLinkOrder) + return mux +} + +// --------------------------------------------------------------------------- +// Request/response payloads +// --------------------------------------------------------------------------- + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name,omitempty"` + Phone string `json:"phone,omitempty"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type linkCartRequest struct { + CartID string `json:"cartId"` +} + +type linkCheckoutRequest struct { + CheckoutID string `json:"checkoutId"` + CartID string `json:"cartId"` +} + +type linkOrderRequest struct { + OrderReference string `json:"orderReference"` + CartID string `json:"cartId"` + Status string `json:"status,omitempty"` +} + +// CustomerResponse mirrors the UCP customer projection (kept local to avoid an +// import cycle with internal/ucp). The password hash is never included. +type CustomerResponse struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Language string `json:"language,omitempty"` + Currency string `json:"currency,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Addresses []AddressResponse `json:"addresses"` + Orders []OrderRef `json:"orders"` +} + +type AddressResponse struct { + ID uint32 `json:"id"` + Label string `json:"label,omitempty"` + FullName string `json:"fullName,omitempty"` + AddressLine1 string `json:"addressLine1"` + AddressLine2 string `json:"addressLine2,omitempty"` + City string `json:"city"` + Zip string `json:"zip"` + Country string `json:"country"` + IsDefaultShipping bool `json:"isDefaultShipping,omitempty"` + IsDefaultBilling bool `json:"isDefaultBilling,omitempty"` +} + +type OrderRef struct { + OrderReference string `json:"orderReference"` + Status string `json:"status,omitempty"` +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return + } + email := NormalizeEmail(req.Email) + if !validEmail(email) { + writeErr(w, http.StatusBadRequest, "a valid email is required") + return + } + if len(req.Password) < minPasswordLen { + writeErr(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + if _, exists := s.store.Get(email); exists { + writeErr(w, http.StatusConflict, "an account with this email already exists") + return + } + + hash, err := HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "could not hash password") + return + } + + rawID, err := profile.NewProfileId() + if err != nil { + writeErr(w, http.StatusInternalServerError, "could not generate id") + return + } + id := uint64(rawID) + + set := &messages.SetProfile{Email: &email} + if req.Name != "" { + set.Name = &req.Name + } + if req.Phone != "" { + set.Phone = &req.Phone + } + if _, err := s.applier.Apply(r.Context(), id, set); err != nil { + writeErr(w, http.StatusInternalServerError, "could not create profile") + return + } + + // Persist the credential only after the profile is created. If this fails + // the profile exists but is unreachable by login — acceptable and rare. + if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil { + if err == ErrEmailExists { + writeErr(w, http.StatusConflict, "an account with this email already exists") + return + } + writeErr(w, http.StatusInternalServerError, "could not store credentials") + return + } + + s.issueSession(w, r, id) + s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return + } + rec, ok := s.store.Get(req.Email) + // Always run a verify (even on miss, against the stored hash if present) and + // return a single generic error so the response does not reveal whether the + // email exists. + if !ok || !VerifyPassword(req.Password, rec.Hash) { + writeErr(w, http.StatusUnauthorized, "invalid email or password") + return + } + s.issueSession(w, r, rec.ProfileID) + s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + ClearSession(w, r) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireSession(w, r) + if !ok { + return + } + s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id) +} + +func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireSession(w, r) + if !ok { + return + } + var req linkCartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return + } + cartID, ok := parseBase62(req.CartID) + if !ok { + writeErr(w, http.StatusBadRequest, "invalid cartId") + return + } + if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCart{CartId: cartID, Label: "current cart"}); err != nil { + writeErr(w, http.StatusInternalServerError, "could not link cart") + return + } + s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id) +} + +func (s *Server) handleLinkCheckout(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireSession(w, r) + if !ok { + return + } + var req linkCheckoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return + } + checkoutID, ok := parseBase62(req.CheckoutID) + if !ok { + writeErr(w, http.StatusBadRequest, "invalid checkoutId") + return + } + cartID, _ := parseBase62(req.CartID) + if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCheckout{CheckoutId: checkoutID, CartId: cartID}); err != nil { + writeErr(w, http.StatusInternalServerError, "could not link checkout") + return + } + s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id) +} + +func (s *Server) handleLinkOrder(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireSession(w, r) + if !ok { + return + } + var req linkOrderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return + } + if strings.TrimSpace(req.OrderReference) == "" { + writeErr(w, http.StatusBadRequest, "orderReference is required") + return + } + cartID, _ := parseBase62(req.CartID) + if _, err := s.applier.Apply(r.Context(), id, &messages.LinkOrder{ + OrderReference: req.OrderReference, + CartId: cartID, + Status: req.Status, + }); err != nil { + writeErr(w, http.StatusInternalServerError, "could not link order") + return + } + s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64) { + SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl) +} + +// requireSession reads and validates the session cookie, writing 401 on +// failure. It returns the profile id and whether a valid session was present. +func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) { + c, err := r.Cookie(SessionCookieName) + if err != nil || c.Value == "" { + writeErr(w, http.StatusUnauthorized, "not authenticated") + return 0, false + } + id, err := s.signer.Parse(c.Value) + if err != nil { + writeErr(w, http.StatusUnauthorized, "not authenticated") + return 0, false + } + return id, true +} + +// writeCustomer loads the profile grain and writes it as a CustomerResponse. +func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status int, idStr string, id uint64) { + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "could not read profile") + return + } + writeJSON(w, status, grainToCustomer(idStr, g)) +} + +func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse { + resp := CustomerResponse{ + ID: id, + Name: g.Name, + Email: g.Email, + Phone: g.Phone, + Language: g.Language, + Currency: g.Currency, + AvatarURL: g.AvatarUrl, + Addresses: make([]AddressResponse, 0, len(g.Addresses)), + Orders: make([]OrderRef, 0, len(g.Orders)), + } + for _, a := range g.Addresses { + resp.Addresses = append(resp.Addresses, AddressResponse{ + ID: a.Id, + Label: a.Label, + FullName: a.FullName, + AddressLine1: a.AddressLine1, + AddressLine2: a.AddressLine2, + City: a.City, + Zip: a.Zip, + Country: a.Country, + IsDefaultShipping: a.IsDefaultShipping, + IsDefaultBilling: a.IsDefaultBilling, + }) + } + for _, o := range g.Orders { + resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status}) + } + return resp +} + +// parseBase62 decodes a base62 id (cart/checkout/profile share the scheme). +func parseBase62(s string) (uint64, bool) { + if s == "" { + return 0, false + } + id, ok := profile.ParseProfileId(s) + return uint64(id), ok +} + +func validEmail(email string) bool { + if email == "" { + return false + } + _, err := mail.ParseAddress(email) + return err == nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/customerauth/session.go b/internal/customerauth/session.go new file mode 100644 index 0000000..1067112 --- /dev/null +++ b/internal/customerauth/session.go @@ -0,0 +1,123 @@ +package customerauth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// SessionCookieName is the name of the HttpOnly cookie that carries the signed +// session token for a logged-in customer. +const SessionCookieName = "sid" + +// DefaultSessionTTL is how long an issued session stays valid. +const DefaultSessionTTL = 30 * 24 * time.Hour + +var ( + // ErrInvalidToken is returned when a token is malformed or its signature + // does not verify. + ErrInvalidToken = errors.New("customerauth: invalid session token") + // ErrExpiredToken is returned when a token's signature is valid but it has + // passed its expiry. + ErrExpiredToken = errors.New("customerauth: expired session token") +) + +// Signer issues and verifies session tokens using an HMAC-SHA256 key. +type Signer struct { + secret []byte +} + +// NewSigner returns a Signer over the given secret key bytes. +func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} } + +// token format: base64url(.) "." base64url(hmac) +// The payload is signed, not encrypted — it carries no secrets, only the +// profile id and expiry, and the HMAC makes it tamper-evident. + +// Issue returns a signed token for profileID that expires after ttl. +func (s *Signer) Issue(profileID uint64, ttl time.Duration) string { + exp := time.Now().Add(ttl).Unix() + payload := fmt.Sprintf("%d.%d", profileID, exp) + b := base64.RawURLEncoding.EncodeToString([]byte(payload)) + return b + "." + s.sign(b) +} + +// Parse verifies a token and returns the profile id it carries. It returns +// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired. +func (s *Signer) Parse(token string) (uint64, error) { + b, sig, ok := strings.Cut(token, ".") + if !ok || b == "" || sig == "" { + return 0, ErrInvalidToken + } + if !hmac.Equal([]byte(sig), []byte(s.sign(b))) { + return 0, ErrInvalidToken + } + raw, err := base64.RawURLEncoding.DecodeString(b) + if err != nil { + return 0, ErrInvalidToken + } + idStr, expStr, ok := strings.Cut(string(raw), ".") + if !ok { + return 0, ErrInvalidToken + } + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return 0, ErrInvalidToken + } + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil { + return 0, ErrInvalidToken + } + if time.Now().Unix() >= exp { + return 0, ErrExpiredToken + } + return id, nil +} + +func (s *Signer) sign(b string) string { + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(b)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// SetSession writes the session cookie for token onto w. The Secure flag is set +// when the request arrived over TLS (directly or via an https-terminating +// proxy) so the cookie still works on plain-http localhost in dev. +func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: isSecure(r), + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(ttl), + MaxAge: int(ttl.Seconds()), + }) +} + +// ClearSession expires the session cookie on w. +func ClearSession(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: isSecure(r), + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func isSecure(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} diff --git a/internal/customerauth/store.go b/internal/customerauth/store.go new file mode 100644 index 0000000..93bfa74 --- /dev/null +++ b/internal/customerauth/store.go @@ -0,0 +1,119 @@ +package customerauth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// ErrEmailExists is returned by Register when the (normalized) email is already +// associated with a credential record. +var ErrEmailExists = errors.New("customerauth: email already registered") + +// Record is a single stored credential: which profile an email belongs to and +// its password hash. It deliberately does not embed any profile data — the +// profile grain remains the source of truth for that. +type Record struct { + Email string `json:"email"` + ProfileID uint64 `json:"profileId"` + Hash string `json:"hash"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// CredentialStore is a small email→credential index persisted to a JSON file. +// It is the email lookup that the event-sourced profile grains intentionally +// lack. Concurrency-safe; writes are atomic (temp file + rename). +type CredentialStore struct { + mu sync.RWMutex + path string + byMail map[string]Record // key: normalized email +} + +// LoadCredentialStore opens (or initializes) the store backed by path. A +// missing file is treated as an empty store; the file is created on first write. +func LoadCredentialStore(path string) (*CredentialStore, error) { + s := &CredentialStore{path: path, byMail: make(map[string]Record)} + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + return nil, fmt.Errorf("customerauth: read %s: %w", path, err) + } + var records []Record + if len(data) > 0 { + if err := json.Unmarshal(data, &records); err != nil { + return nil, fmt.Errorf("customerauth: parse %s: %w", path, err) + } + } + for _, r := range records { + s.byMail[NormalizeEmail(r.Email)] = r + } + return s, nil +} + +// NormalizeEmail lower-cases and trims an email for use as a lookup key. +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +// Get returns the record for email and whether it exists. +func (s *CredentialStore) Get(email string) (Record, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, ok := s.byMail[NormalizeEmail(email)] + return r, ok +} + +// Register adds a new credential record and persists the store. It returns +// ErrEmailExists if the email is already taken. +func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error { + key := NormalizeEmail(email) + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.byMail[key]; exists { + return ErrEmailExists + } + s.byMail[key] = Record{Email: key, ProfileID: profileID, Hash: hash, CreatedAt: createdAt} + return s.persistLocked() +} + +// persistLocked writes the whole store to disk atomically. Caller holds s.mu. +func (s *CredentialStore) persistLocked() error { + records := make([]Record, 0, len(s.byMail)) + for _, r := range s.byMail { + records = append(records, r) + } + data, err := json.MarshalIndent(records, "", " ") + if err != nil { + return fmt.Errorf("customerauth: marshal store: %w", err) + } + if dir := filepath.Dir(s.path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("customerauth: mkdir %s: %w", dir, err) + } + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".credentials-*.tmp") + if err != nil { + return fmt.Errorf("customerauth: temp file: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("customerauth: write temp: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("customerauth: close temp: %w", err) + } + if err := os.Rename(tmpName, s.path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("customerauth: rename temp: %w", err) + } + return nil +} diff --git a/pkg/actor/simple_grain_pool.go b/pkg/actor/simple_grain_pool.go index 991ef28..0377f9c 100644 --- a/pkg/actor/simple_grain_pool.go +++ b/pkg/actor/simple_grain_pool.go @@ -43,6 +43,9 @@ type GrainPoolConfig[V any] struct { Hostname string Spawn func(ctx context.Context, id uint64) (Grain[V], error) SpawnHost func(host string) (Host[V], error) + // Destroy is an optional cleanup hook called when a grain is evicted on TTL + // (e.g. to detach inventory subscriptions wired into its mutations). Leave + // nil when there is nothing to clean up — purge() skips a nil Destroy. Destroy func(grain Grain[V]) error TTL time.Duration PoolSize int @@ -96,8 +99,10 @@ func (p *SimpleGrainPool[V]) purge() { for id, grain := range p.grains { if grain.GetLastAccess().Before(purgeLimit) { purgedIds = append(purgedIds, id) - if err := p.destroy(grain); err != nil { - log.Printf("failed to destroy grain %d: %v", id, err) + if p.destroy != nil { + if err := p.destroy(grain); err != nil { + log.Printf("failed to destroy grain %d: %v", id, err) + } } delete(p.grains, id)