more customer
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPasswordRoundTrip(t *testing.T) {
|
||||
h, err := HashPassword("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
if !VerifyPassword("correct horse battery staple", h) {
|
||||
t.Fatal("correct password did not verify")
|
||||
}
|
||||
if VerifyPassword("wrong", h) {
|
||||
t.Fatal("wrong password verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashesAreSalted(t *testing.T) {
|
||||
a, _ := HashPassword("hunter2hunter2")
|
||||
b, _ := HashPassword("hunter2hunter2")
|
||||
if a == b {
|
||||
t.Fatal("two hashes of the same password are identical — not salted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsMalformed(t *testing.T) {
|
||||
for _, bad := range []string{"", "x", "pbkdf2-sha256$abc$salt$hash", "md5$1$a$b"} {
|
||||
if VerifyPassword("whatever", bad) {
|
||||
t.Fatalf("malformed hash %q verified", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.Issue(12345, time.Hour)
|
||||
id, err := s.Parse(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if id != 12345 {
|
||||
t.Fatalf("got id %d, want 12345", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpired(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.Issue(1, -time.Second)
|
||||
if _, err := s.Parse(tok); err != ErrExpiredToken {
|
||||
t.Fatalf("got %v, want ErrExpiredToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTamperAndWrongKey(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.Issue(7, time.Hour)
|
||||
if _, err := s.Parse(tok + "x"); err != ErrInvalidToken {
|
||||
t.Fatalf("tampered token: got %v, want ErrInvalidToken", err)
|
||||
}
|
||||
other := NewSigner([]byte("different-secret"))
|
||||
if _, err := other.Parse(tok); err != ErrInvalidToken {
|
||||
t.Fatalf("wrong key: got %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
// Duplicate (case-insensitive) is rejected.
|
||||
if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
||||
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
|
||||
}
|
||||
// Reload from disk and confirm the record persisted.
|
||||
reloaded, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
rec, ok := reloaded.Get("USER@EXAMPLE.COM")
|
||||
if !ok {
|
||||
t.Fatal("record not found after reload")
|
||||
}
|
||||
if rec.ProfileID != 42 || rec.Hash != "hash1" {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package customerauth adds password-based signup/login to the profile service.
|
||||
//
|
||||
// It deliberately uses only the standard library: PBKDF2-HMAC-SHA256 for
|
||||
// password hashing (crypto/pbkdf2, Go 1.24+) and HMAC-SHA256-signed cookies for
|
||||
// sessions (crypto/hmac). No password ever leaves the server in plaintext and
|
||||
// the session token is opaque + tamper-evident.
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pbkdf2Iterations is the work factor for PBKDF2-HMAC-SHA256. 210_000 matches
|
||||
// the current OWASP recommendation for PBKDF2-SHA256.
|
||||
const pbkdf2Iterations = 210_000
|
||||
|
||||
const (
|
||||
saltLen = 16
|
||||
keyLen = 32
|
||||
)
|
||||
|
||||
// HashPassword returns an encoded PBKDF2 hash of the form
|
||||
//
|
||||
// pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>
|
||||
//
|
||||
// with a fresh random salt. The encoded string is self-describing so Verify can
|
||||
// read the parameters back without external configuration.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("customerauth: read salt: %w", err)
|
||||
}
|
||||
dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, keyLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("customerauth: pbkdf2: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
|
||||
pbkdf2Iterations,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(dk),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword reports whether password matches the encoded hash. It is
|
||||
// constant-time in the hash comparison and returns false for any malformed
|
||||
// encoding rather than erroring, so callers can treat it as a simple predicate.
|
||||
func VerifyPassword(password, encoded string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iter, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iter <= 0 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookieName is the name of the HttpOnly cookie that carries the signed
|
||||
// session token for a logged-in customer.
|
||||
const SessionCookieName = "sid"
|
||||
|
||||
// DefaultSessionTTL is how long an issued session stays valid.
|
||||
const DefaultSessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
var (
|
||||
// ErrInvalidToken is returned when a token is malformed or its signature
|
||||
// does not verify.
|
||||
ErrInvalidToken = errors.New("customerauth: invalid session token")
|
||||
// ErrExpiredToken is returned when a token's signature is valid but it has
|
||||
// passed its expiry.
|
||||
ErrExpiredToken = errors.New("customerauth: expired session token")
|
||||
)
|
||||
|
||||
// Signer issues and verifies session tokens using an HMAC-SHA256 key.
|
||||
type Signer struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewSigner returns a Signer over the given secret key bytes.
|
||||
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
|
||||
|
||||
// token format: base64url(<profileID>.<expUnix>) "." base64url(hmac)
|
||||
// The payload is signed, not encrypted — it carries no secrets, only the
|
||||
// profile id and expiry, and the HMAC makes it tamper-evident.
|
||||
|
||||
// Issue returns a signed token for profileID that expires after ttl.
|
||||
func (s *Signer) Issue(profileID uint64, ttl time.Duration) string {
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
payload := fmt.Sprintf("%d.%d", profileID, exp)
|
||||
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return b + "." + s.sign(b)
|
||||
}
|
||||
|
||||
// Parse verifies a token and returns the profile id it carries. It returns
|
||||
// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired.
|
||||
func (s *Signer) Parse(token string) (uint64, error) {
|
||||
b, sig, ok := strings.Cut(token, ".")
|
||||
if !ok || b == "" || sig == "" {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(b)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
idStr, expStr, ok := strings.Cut(string(raw), ".")
|
||||
if !ok {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
if time.Now().Unix() >= exp {
|
||||
return 0, ErrExpiredToken
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Signer) sign(b string) string {
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(b))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// SetSession writes the session cookie for token onto w. The Secure flag is set
|
||||
// when the request arrived over TLS (directly or via an https-terminating
|
||||
// proxy) so the cookie still works on plain-http localhost in dev.
|
||||
func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(ttl),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSession expires the session cookie on w.
|
||||
func ClearSession(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func isSecure(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrEmailExists is returned by Register when the (normalized) email is already
|
||||
// associated with a credential record.
|
||||
var ErrEmailExists = errors.New("customerauth: email already registered")
|
||||
|
||||
// Record is a single stored credential: which profile an email belongs to and
|
||||
// its password hash. It deliberately does not embed any profile data — the
|
||||
// profile grain remains the source of truth for that.
|
||||
type Record struct {
|
||||
Email string `json:"email"`
|
||||
ProfileID uint64 `json:"profileId"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialStore is a small email→credential index persisted to a JSON file.
|
||||
// It is the email lookup that the event-sourced profile grains intentionally
|
||||
// lack. Concurrency-safe; writes are atomic (temp file + rename).
|
||||
type CredentialStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
byMail map[string]Record // key: normalized email
|
||||
}
|
||||
|
||||
// LoadCredentialStore opens (or initializes) the store backed by path. A
|
||||
// missing file is treated as an empty store; the file is created on first write.
|
||||
func LoadCredentialStore(path string) (*CredentialStore, error) {
|
||||
s := &CredentialStore{path: path, byMail: make(map[string]Record)}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("customerauth: read %s: %w", path, err)
|
||||
}
|
||||
var records []Record
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &records); err != nil {
|
||||
return nil, fmt.Errorf("customerauth: parse %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
for _, r := range records {
|
||||
s.byMail[NormalizeEmail(r.Email)] = r
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NormalizeEmail lower-cases and trims an email for use as a lookup key.
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// Get returns the record for email and whether it exists.
|
||||
func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r, ok := s.byMail[NormalizeEmail(email)]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// Register adds a new credential record and persists the store. It returns
|
||||
// ErrEmailExists if the email is already taken.
|
||||
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.byMail[key]; exists {
|
||||
return ErrEmailExists
|
||||
}
|
||||
s.byMail[key] = Record{Email: key, ProfileID: profileID, Hash: hash, CreatedAt: createdAt}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
|
||||
func (s *CredentialStore) persistLocked() error {
|
||||
records := make([]Record, 0, len(s.byMail))
|
||||
for _, r := range s.byMail {
|
||||
records = append(records, r)
|
||||
}
|
||||
data, err := json.MarshalIndent(records, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("customerauth: marshal store: %w", err)
|
||||
}
|
||||
if dir := filepath.Dir(s.path); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("customerauth: mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".credentials-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("customerauth: temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("customerauth: write temp: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("customerauth: close temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("customerauth: rename temp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user