cart and checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+220 -13
View File
@@ -3,8 +3,11 @@ package customerauth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"net/url"
"strconv"
"strings"
"time"
@@ -14,9 +17,24 @@ import (
"google.golang.org/protobuf/proto"
)
// minPasswordLen is the minimum accepted password length at registration.
// 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 {
@@ -24,21 +42,60 @@ type ProfileApplier interface {
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.
// 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 *CredentialStore
applier ProfileApplier
signer *Signer
ttl time.Duration
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 *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server {
func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
if ttl <= 0 {
ttl = DefaultSessionTTL
}
return &Server{store: store, applier: applier, signer: signer, ttl: ttl}
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
@@ -49,6 +106,10 @@ func (s *Server) Handler() http.Handler {
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)
@@ -86,6 +147,22 @@ type linkOrderRequest struct {
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"`
}
// 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 {
@@ -98,6 +175,9 @@ type CustomerResponse struct {
AvatarURL string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses"`
Orders []OrderRef `json:"orders"`
// EmailVerified reflects whether the email-verification flow has completed.
EmailVerified bool `json:"emailVerified"`
}
type AddressResponse struct {
@@ -137,7 +217,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if _, exists := s.store.Get(email); exists {
if _, exists := s.store.Get(r.Context(), email); exists {
writeErr(w, http.StatusConflict, "an account with this email already exists")
return
}
@@ -169,7 +249,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
// 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 := 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
@@ -178,6 +258,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -188,14 +271,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
rec, ok := s.store.Get(req.Email)
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)
}
@@ -213,6 +308,101 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
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 {
@@ -292,6 +482,19 @@ 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) {
@@ -315,7 +518,11 @@ func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status in
writeErr(w, http.StatusInternalServerError, "could not read profile")
return
}
writeJSON(w, status, grainToCustomer(idStr, g))
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 {