cart and checkout
This commit is contained in:
+220
-13
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user