Files
go-cart-actor/internal/customerauth/server.go
T
mats 3df95eac90
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
more customer
2026-06-26 19:49:54 +02:00

379 lines
12 KiB
Go

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})
}