more customer
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user