379 lines
12 KiB
Go
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})
|
|
}
|