package customerauth import ( "context" "encoding/json" "fmt" "net/http" "net/mail" "net/url" "strconv" "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 and // password reset. const minPasswordLen = 8 // Token lifetimes for the email-verification and password-reset links. const ( verifyTokenTTL = 24 * time.Hour resetTokenTTL = 1 * time.Hour ) // Storefront paths (appended to Options.BaseURL) that the verification and reset // links point at. The page reads the token from the query string and POSTs it // back to /verify or /reset. const ( verifyLinkPath = "/account/verify" resetLinkPath = "/account/reset" ) // 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, email verification, password reset and // identity-linking over HTTP, backed by the credential store (email→id+hash) and // the profile grain pool. type Server struct { store Credentials applier ProfileApplier signer *Signer ttl time.Duration limiter Limiter notifier Notifier baseURL string requireVerified bool } // Options configures optional auth-server behavior. The zero value is valid: an // in-memory login limiter (5 failures / 15m), a logging notifier, and no // verified-email requirement for login. type Options struct { // Limiter throttles failed logins and reset requests. Nil installs a default // in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a // horizontally-scaled deployment. Limiter Limiter // Notifier delivers verification and reset links. Nil installs LogNotifier. Notifier Notifier // BaseURL is the public origin used to build links in messages, e.g. // "https://shop.tornberg.me". The verify/reset paths are appended to it. BaseURL string // RequireVerifiedEmail, when true, blocks login until the email is verified. // Off by default so pre-existing accounts (which carry no verification // record) keep working. RequireVerifiedEmail bool } // NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL. func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server { if ttl <= 0 { ttl = DefaultSessionTTL } if opts.Limiter == nil { opts.Limiter = NewLoginLimiter(0, 0) } if opts.Notifier == nil { opts.Notifier = LogNotifier{} } return &Server{ store: store, applier: applier, signer: signer, ttl: ttl, limiter: opts.Limiter, notifier: opts.Notifier, baseURL: strings.TrimRight(opts.BaseURL, "/"), requireVerified: opts.RequireVerifiedEmail, } } // 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 /verify-request", s.handleVerifyRequest) mux.HandleFunc("POST /verify", s.handleVerify) mux.HandleFunc("POST /reset-request", s.handleResetRequest) mux.HandleFunc("POST /reset", s.handleResetComplete) 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"` } // tokenRequest carries a verification token. type tokenRequest struct { Token string `json:"token"` } // resetRequest asks for a password-reset link to be sent to an email. type resetRequest struct { Email string `json:"email"` } // resetCompleteRequest carries a reset token and the new password. type resetCompleteRequest struct { Token string `json:"token"` Password string `json:"password"` } // EmailPreferencesResponse mirrors the UCP email preferences type for the // customer-facing API. type EmailPreferencesResponse struct { OrderEmails *bool `json:"orderEmails,omitempty"` MarketingEmails *bool `json:"marketingEmails,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"` // EmailPreferences carries the customer's current email opt-in/out state. EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"` // EmailVerified reflects whether the email-verification flow has completed. EmailVerified bool `json:"emailVerified"` } 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(r.Context(), 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(r.Context(), 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 } // Send the verification link (best-effort, via the configured notifier). s.sendVerification(email) 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 } key := NormalizeEmail(req.Email) if ok, retry := s.limiter.Allowed(r.Context(), key); !ok { w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") return } rec, ok := s.store.Get(r.Context(), 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) { s.limiter.RecordFailure(r.Context(), key) writeErr(w, http.StatusUnauthorized, "invalid email or password") return } if s.requireVerified && rec.VerifiedAt == "" { writeErr(w, http.StatusForbidden, "email not verified") return } s.limiter.Reset(r.Context(), key) 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) } // handleVerifyRequest re-sends the email-verification link for the logged-in // customer. Requires a session so it can't be used to enumerate addresses. func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) { id, ok := s.requireSession(w, r) if !ok { return } g, err := s.applier.Get(r.Context(), id) if err != nil || g.Email == "" { writeErr(w, http.StatusInternalServerError, "could not read profile") return } s.sendVerification(NormalizeEmail(g.Email)) writeJSON(w, http.StatusOK, map[string]string{"status": "sent"}) } // handleVerify consumes a verification token and marks the email verified. An // unknown subject still returns success so the endpoint reveals nothing. func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { var req tokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token) if err != nil { writeErr(w, http.StatusBadRequest, "invalid or expired token") return } if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil { writeErr(w, http.StatusInternalServerError, "could not record verification") return } writeJSON(w, http.StatusOK, map[string]string{"status": "verified"}) } // handleResetRequest issues a password-reset link for a registered email. It // always returns 200 (whether or not the email exists) so it never reveals // account existence, and is rate-limited per email to prevent mail-bombing. func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) { var req resetRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } email := NormalizeEmail(req.Email) limiterKey := "reset:" + email if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok { w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) writeErr(w, http.StatusTooManyRequests, "too many requests, try again later") return } if _, exists := s.store.Get(r.Context(), email); exists { token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL) s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token)) } s.limiter.RecordFailure(r.Context(), limiterKey) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // handleResetComplete consumes a reset token and replaces the password hash. func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) { var req resetCompleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } if len(req.Password) < minPasswordLen { writeErr(w, http.StatusBadRequest, "password must be at least 8 characters") return } email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token) if err != nil { writeErr(w, http.StatusBadRequest, "invalid or expired token") return } hash, err := HashPassword(req.Password) if err != nil { writeErr(w, http.StatusInternalServerError, "could not hash password") return } found, err := s.store.UpdateHash(r.Context(), email, hash) if err != nil { writeErr(w, http.StatusInternalServerError, "could not update password") return } if !found { // Token signed for an email no longer present. writeErr(w, http.StatusBadRequest, "invalid or expired token") return } s.limiter.Reset(r.Context(), NormalizeEmail(email)) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } 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) } // sendVerification mints a verification token for email and hands the link to // the notifier. email must already be normalized. func (s *Server) sendVerification(email string) { token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL) s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token)) } // link builds an absolute link to a storefront path carrying the token as a // query parameter. With no configured BaseURL it returns a relative link. func (s *Server) link(path, token string) string { return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token)) } // 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 } resp := grainToCustomer(idStr, g) if rec, ok := s.store.Get(r.Context(), g.Email); ok { resp.EmailVerified = rec.VerifiedAt != "" } writeJSON(w, status, resp) } 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}) } if g.EmailPreferences != nil { resp.EmailPreferences = &EmailPreferencesResponse{ OrderEmails: g.EmailPreferences.OrderEmails, MarketingEmails: g.EmailPreferences.MarketingEmails, } } 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}) }