cart and checkout
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,11 +74,12 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil {
|
||||
ctx := context.Background()
|
||||
if err := store.Register(ctx, "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 {
|
||||
if err := store.Register(ctx, "user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
||||
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
|
||||
}
|
||||
// Reload from disk and confirm the record persisted.
|
||||
@@ -85,7 +87,7 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
rec, ok := reloaded.Get("USER@EXAMPLE.COM")
|
||||
rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM")
|
||||
if !ok {
|
||||
t.Fatal("record not found after reload")
|
||||
}
|
||||
@@ -93,3 +95,39 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDelete(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := store.Register(ctx, "user@example.com", 42, "hash1", "ts"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Delete
|
||||
ok, err := store.Delete(ctx, "USER@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected delete to report email was found")
|
||||
}
|
||||
|
||||
// Confirm not found
|
||||
if _, ok := store.Get(ctx, "user@example.com"); ok {
|
||||
t.Fatal("record still exists after delete")
|
||||
}
|
||||
|
||||
// Reload from disk and verify it's gone
|
||||
reloaded, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if _, ok := reloaded.Get(ctx, "user@example.com"); ok {
|
||||
t.Fatal("record still exists after reload")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPurposeTokenRoundTrip(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.IssuePurpose(purposePasswordReset, "user@example.com", time.Hour)
|
||||
sub, err := s.ParsePurpose(purposePasswordReset, tok)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if sub != "user@example.com" {
|
||||
t.Fatalf("got subject %q, want user@example.com", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurposeTokenWrongPurposeRejected(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
|
||||
// A verify token must not be accepted as a reset token.
|
||||
if _, err := s.ParsePurpose(purposePasswordReset, tok); err != ErrInvalidToken {
|
||||
t.Fatalf("got %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurposeTokenExpiredAndTampered(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
expired := s.IssuePurpose(purposeVerifyEmail, "user@example.com", -time.Second)
|
||||
if _, err := s.ParsePurpose(purposeVerifyEmail, expired); err != ErrExpiredToken {
|
||||
t.Fatalf("expired: got %v, want ErrExpiredToken", err)
|
||||
}
|
||||
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
|
||||
if _, err := s.ParsePurpose(purposeVerifyEmail, tok+"x"); err != ErrInvalidToken {
|
||||
t.Fatalf("tampered: got %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterLocksOutAndResets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
l := NewLoginLimiter(3, time.Minute)
|
||||
const key = "user@example.com"
|
||||
for i := range 3 {
|
||||
if ok, _ := l.Allowed(ctx, key); !ok {
|
||||
t.Fatalf("locked out early after %d failures", i)
|
||||
}
|
||||
l.RecordFailure(ctx, key)
|
||||
}
|
||||
ok, retry := l.Allowed(ctx, key)
|
||||
if ok {
|
||||
t.Fatal("expected lockout after 3 failures")
|
||||
}
|
||||
if retry <= 0 {
|
||||
t.Fatalf("expected positive retry-after, got %v", retry)
|
||||
}
|
||||
// A successful auth clears the history.
|
||||
l.Reset(ctx, key)
|
||||
if ok, _ := l.Allowed(ctx, key); !ok {
|
||||
t.Fatal("expected reset to clear lockout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterNilIsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var l *LoginLimiter
|
||||
if ok, _ := l.Allowed(ctx, "x"); !ok {
|
||||
t.Fatal("nil limiter should allow")
|
||||
}
|
||||
l.RecordFailure(ctx, "x") // must not panic
|
||||
l.Reset(ctx, "x") // must not panic
|
||||
}
|
||||
|
||||
func TestStoreMarkVerifiedAndUpdateHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if err := store.Register(ctx, "u@example.com", 1, "hash1", "ts"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Unknown email is a no-op, not an error.
|
||||
if found, err := store.MarkVerified(ctx, "missing@example.com", "now"); err != nil || found {
|
||||
t.Fatalf("MarkVerified(missing) = (%v, %v), want (false, nil)", found, err)
|
||||
}
|
||||
|
||||
if found, err := store.MarkVerified(ctx, "U@EXAMPLE.COM", "2026-01-01T00:00:00Z"); err != nil || !found {
|
||||
t.Fatalf("MarkVerified = (%v, %v), want (true, nil)", found, err)
|
||||
}
|
||||
if found, err := store.UpdateHash(ctx, "u@example.com", "hash2"); err != nil || !found {
|
||||
t.Fatalf("UpdateHash = (%v, %v), want (true, nil)", found, err)
|
||||
}
|
||||
|
||||
// Both changes survive a reload from disk.
|
||||
reloaded, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
rec, ok := reloaded.Get(ctx, "u@example.com")
|
||||
if !ok {
|
||||
t.Fatal("record missing after reload")
|
||||
}
|
||||
if rec.Hash != "hash2" {
|
||||
t.Fatalf("hash = %q, want hash2", rec.Hash)
|
||||
}
|
||||
if rec.VerifiedAt == "" {
|
||||
t.Fatal("verifiedAt not persisted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package customerauth
|
||||
|
||||
import "log"
|
||||
|
||||
// Notifier delivers transactional auth messages (email verification and
|
||||
// password reset). The auth server only builds the links; delivery is pluggable
|
||||
// so this is the seam where a real mailer — or the planned notification service
|
||||
// (commerce-maturity plan, section C3) — gets wired in. The default LogNotifier
|
||||
// just logs the link, which is enough for local development.
|
||||
type Notifier interface {
|
||||
SendEmailVerification(email, link string)
|
||||
SendPasswordReset(email, link string)
|
||||
}
|
||||
|
||||
// LogNotifier writes the links to the standard logger instead of sending them.
|
||||
type LogNotifier struct{}
|
||||
|
||||
func (LogNotifier) SendEmailVerification(email, link string) {
|
||||
log.Printf("customerauth: email verification for %s: %s", email, link)
|
||||
}
|
||||
|
||||
func (LogNotifier) SendPasswordReset(email, link string) {
|
||||
log.Printf("customerauth: password reset for %s: %s", email, link)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter throttles repeated failures for a key (login email or "reset:"-prefixed
|
||||
// email). It is satisfied by the in-memory LoginLimiter (single-instance / dev)
|
||||
// and by RedisLoginLimiter (shared, horizontally scalable).
|
||||
type Limiter interface {
|
||||
// Allowed reports whether an attempt for key may proceed, and when locked the
|
||||
// remaining lockout duration (for a Retry-After header).
|
||||
Allowed(ctx context.Context, key string) (bool, time.Duration)
|
||||
// RecordFailure registers a failed attempt for key.
|
||||
RecordFailure(ctx context.Context, key string)
|
||||
// Reset clears the failure history for key (call on a successful auth).
|
||||
Reset(ctx context.Context, key string)
|
||||
}
|
||||
|
||||
// LoginLimiter is an in-memory failed-attempt limiter that locks a key out after
|
||||
// too many failures inside a rolling window. It is per-process — matching the
|
||||
// credential store's single-instance scope; a horizontally-scaled deployment
|
||||
// would need a shared backing store. Keys are typically the normalized email
|
||||
// (login) or a "reset:"-prefixed email (reset requests).
|
||||
type LoginLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptState
|
||||
max int // failures allowed in the window before lockout
|
||||
window time.Duration // rolling window and lockout duration
|
||||
}
|
||||
|
||||
type attemptState struct {
|
||||
count int
|
||||
first time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
// NewLoginLimiter builds a limiter allowing max failures per window before a
|
||||
// lockout of window duration. Zero/negative values fall back to 5 per 15m.
|
||||
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
|
||||
if max <= 0 {
|
||||
max = 5
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 15 * time.Minute
|
||||
}
|
||||
return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window}
|
||||
}
|
||||
|
||||
// Allowed reports whether an attempt for key may proceed. When locked it also
|
||||
// returns the remaining lockout duration (suitable for a Retry-After header). A
|
||||
// nil limiter always allows (disabled).
|
||||
func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) {
|
||||
if l == nil {
|
||||
return true, 0
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.attempts[key]
|
||||
if st == nil {
|
||||
return true, 0
|
||||
}
|
||||
if now.Before(st.lockedUntil) {
|
||||
return false, st.lockedUntil.Sub(now)
|
||||
}
|
||||
// Window elapsed since the first failure: forget the history.
|
||||
if now.Sub(st.first) > l.window {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// RecordFailure registers a failed attempt for key, locking it for window once
|
||||
// max failures accumulate inside the window.
|
||||
func (l *LoginLimiter) RecordFailure(_ context.Context, key string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.attempts[key]
|
||||
if st == nil || now.Sub(st.first) > l.window {
|
||||
l.attempts[key] = &attemptState{count: 1, first: now}
|
||||
return
|
||||
}
|
||||
st.count++
|
||||
if st.count >= l.max {
|
||||
st.lockedUntil = now.Add(l.window)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears the failure history for key. Call it on a successful auth.
|
||||
func (l *LoginLimiter) Reset(_ context.Context, key string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
delete(l.attempts, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Redis-backed implementations of Credentials and Limiter. They let the profile
|
||||
// service run horizontally: every replica reads/writes the same credential
|
||||
// records and shares one failed-attempt counter, instead of each holding its own
|
||||
// file map and in-process counter. Session and verify/reset tokens are already
|
||||
// stateless HMAC values, so Redis + a shared CUSTOMER_AUTH_SECRET is all that
|
||||
// the auth surface needs to scale out.
|
||||
|
||||
const (
|
||||
credKeyPrefix = "customerauth:cred:" // hash per normalized email
|
||||
failKeyPrefix = "customerauth:fail:" // failure counter per key
|
||||
lockKeyPrefix = "customerauth:lock:" // lockout marker per key
|
||||
)
|
||||
|
||||
// Compile-time guarantees that both backings satisfy the server's interfaces.
|
||||
var (
|
||||
_ Credentials = (*CredentialStore)(nil)
|
||||
_ Credentials = (*RedisCredentialStore)(nil)
|
||||
_ Limiter = (*LoginLimiter)(nil)
|
||||
_ Limiter = (*RedisLoginLimiter)(nil)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RedisCredentialStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RedisCredentialStore stores each credential as a Redis hash at
|
||||
// "customerauth:cred:<normalized-email>". Register/MarkVerified/UpdateHash are
|
||||
// atomic via small Lua scripts so concurrent replicas can't race a duplicate
|
||||
// registration or a lost update.
|
||||
type RedisCredentialStore struct {
|
||||
client *redis.Client
|
||||
luaRegister *redis.Script
|
||||
luaVerified *redis.Script
|
||||
luaUpdHash *redis.Script
|
||||
}
|
||||
|
||||
// registerScript creates the hash only if it does not already exist, returning 1
|
||||
// on create and 0 if the email is taken.
|
||||
const registerScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
||||
redis.call('HSET', KEYS[1], 'email', ARGV[1], 'profileId', ARGV[2], 'hash', ARGV[3], 'createdAt', ARGV[4])
|
||||
return 1`
|
||||
|
||||
// markVerifiedScript sets verifiedAt only when missing. Returns -1 if the email
|
||||
// is unknown, 1 otherwise (whether it set the field now or was already verified).
|
||||
const markVerifiedScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
|
||||
local v = redis.call('HGET', KEYS[1], 'verifiedAt')
|
||||
if v and v ~= '' then return 1 end
|
||||
redis.call('HSET', KEYS[1], 'verifiedAt', ARGV[1])
|
||||
return 1`
|
||||
|
||||
// updateHashScript replaces the password hash. Returns 0 if unknown, 1 on update.
|
||||
const updateHashScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
||||
redis.call('HSET', KEYS[1], 'hash', ARGV[1])
|
||||
return 1`
|
||||
|
||||
// NewRedisCredentialStore builds a Redis-backed credential store and verifies
|
||||
// connectivity with a ping.
|
||||
func NewRedisCredentialStore(ctx context.Context, client *redis.Client) (*RedisCredentialStore, error) {
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedisCredentialStore{
|
||||
client: client,
|
||||
luaRegister: redis.NewScript(registerScript),
|
||||
luaVerified: redis.NewScript(markVerifiedScript),
|
||||
luaUpdHash: redis.NewScript(updateHashScript),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) Get(ctx context.Context, email string) (Record, bool) {
|
||||
key := credKeyPrefix + NormalizeEmail(email)
|
||||
m, err := s.client.HGetAll(ctx, key).Result()
|
||||
if err != nil {
|
||||
log.Printf("customerauth: redis Get(%s): %v", key, err)
|
||||
return Record{}, false
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return Record{}, false
|
||||
}
|
||||
pid, _ := strconv.ParseUint(m["profileId"], 10, 64)
|
||||
return Record{
|
||||
Email: m["email"],
|
||||
ProfileID: pid,
|
||||
Hash: m["hash"],
|
||||
CreatedAt: m["createdAt"],
|
||||
VerifiedAt: m["verifiedAt"],
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error {
|
||||
norm := NormalizeEmail(email)
|
||||
created, err := s.luaRegister.Run(ctx, s.client,
|
||||
[]string{credKeyPrefix + norm},
|
||||
norm, strconv.FormatUint(profileID, 10), hash, createdAt,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created == 0 {
|
||||
return ErrEmailExists
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error) {
|
||||
res, err := s.luaVerified.Run(ctx, s.client,
|
||||
[]string{credKeyPrefix + NormalizeEmail(email)}, verifiedAt,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res >= 0, nil // -1 means unknown email
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) UpdateHash(ctx context.Context, email, hash string) (bool, error) {
|
||||
res, err := s.luaUpdHash.Run(ctx, s.client,
|
||||
[]string{credKeyPrefix + NormalizeEmail(email)}, hash,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res == 1, nil
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) Delete(ctx context.Context, email string) (bool, error) {
|
||||
key := credKeyPrefix + NormalizeEmail(email)
|
||||
res, err := s.client.Del(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res > 0, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RedisLoginLimiter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RedisLoginLimiter is the shared-storage counterpart of LoginLimiter: the
|
||||
// failure counter and lockout marker live in Redis with native TTLs, so the
|
||||
// window/lockout are enforced across all replicas. It fails open (allows the
|
||||
// attempt) on a Redis error so an outage degrades to "no rate limiting" rather
|
||||
// than locking every customer out.
|
||||
type RedisLoginLimiter struct {
|
||||
client *redis.Client
|
||||
luaRecord *redis.Script
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
// recordFailureScript increments the failure counter (setting the window TTL on
|
||||
// the first failure) and, once it reaches max, writes a lockout marker with the
|
||||
// same TTL.
|
||||
const recordFailureScript = `
|
||||
local cnt = redis.call('INCR', KEYS[1])
|
||||
if cnt == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
|
||||
if cnt >= tonumber(ARGV[1]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) end
|
||||
return cnt`
|
||||
|
||||
// NewRedisLoginLimiter builds a Redis-backed limiter. Zero/negative values fall
|
||||
// back to 5 failures per 15 minutes (matching the in-memory default).
|
||||
func NewRedisLoginLimiter(client *redis.Client, max int, window time.Duration) *RedisLoginLimiter {
|
||||
if max <= 0 {
|
||||
max = 5
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 15 * time.Minute
|
||||
}
|
||||
return &RedisLoginLimiter{
|
||||
client: client,
|
||||
luaRecord: redis.NewScript(recordFailureScript),
|
||||
max: max,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RedisLoginLimiter) Allowed(ctx context.Context, key string) (bool, time.Duration) {
|
||||
ttl, err := l.client.PTTL(ctx, lockKeyPrefix+key).Result()
|
||||
if err != nil {
|
||||
log.Printf("customerauth: redis limiter Allowed(%s): %v", key, err)
|
||||
return true, 0 // fail open
|
||||
}
|
||||
if ttl > 0 {
|
||||
return false, ttl
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (l *RedisLoginLimiter) RecordFailure(ctx context.Context, key string) {
|
||||
if err := l.luaRecord.Run(ctx, l.client,
|
||||
[]string{failKeyPrefix + key, lockKeyPrefix + key},
|
||||
strconv.Itoa(l.max), strconv.Itoa(int(l.window.Seconds())),
|
||||
).Err(); err != nil {
|
||||
log.Printf("customerauth: redis limiter RecordFailure(%s): %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RedisLoginLimiter) Reset(ctx context.Context, key string) {
|
||||
if err := l.client.Del(ctx, failKeyPrefix+key, lockKeyPrefix+key).Err(); err != nil {
|
||||
log.Printf("customerauth: redis limiter Reset(%s): %v", key, err)
|
||||
}
|
||||
}
|
||||
+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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,14 +15,36 @@ import (
|
||||
// associated with a credential record.
|
||||
var ErrEmailExists = errors.New("customerauth: email already registered")
|
||||
|
||||
// Credentials is the email→credential index the auth server depends on. It is
|
||||
// satisfied by the file-backed CredentialStore (single-instance / dev) and by
|
||||
// RedisCredentialStore (shared, horizontally scalable). All methods normalize
|
||||
// the email key internally.
|
||||
type Credentials interface {
|
||||
// Get returns the record for email and whether it exists. A backing-store
|
||||
// failure is reported as "not found" so callers fail closed.
|
||||
Get(ctx context.Context, email string) (Record, bool)
|
||||
// Register adds a new credential, returning ErrEmailExists if taken.
|
||||
Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error
|
||||
// MarkVerified records email verification. The bool reports whether the
|
||||
// email was known; an unknown email is not an error (avoids enumeration).
|
||||
MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error)
|
||||
// UpdateHash replaces the password hash. The bool reports whether the email
|
||||
// was known; an unknown email is not an error.
|
||||
UpdateHash(ctx context.Context, email, hash string) (bool, error)
|
||||
// Delete removes a credential record by email. The bool reports whether the email
|
||||
// was known.
|
||||
Delete(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Email string `json:"email"`
|
||||
ProfileID uint64 `json:"profileId"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
VerifiedAt string `json:"verifiedAt,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialStore is a small email→credential index persisted to a JSON file.
|
||||
@@ -61,8 +84,9 @@ 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) {
|
||||
// Get returns the record for email and whether it exists. The ctx is accepted
|
||||
// for interface parity with the Redis store; the file store ignores it.
|
||||
func (s *CredentialStore) Get(_ context.Context, email string) (Record, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r, ok := s.byMail[NormalizeEmail(email)]
|
||||
@@ -71,7 +95,7 @@ func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||
|
||||
// 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 {
|
||||
func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -82,6 +106,54 @@ func (s *CredentialStore) Register(email string, profileID uint64, hash, created
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// MarkVerified records that email completed email verification and persists the
|
||||
// store. It is a no-op if already verified. The bool reports whether the email
|
||||
// was known; an unknown email is not an error (callers avoid leaking which
|
||||
// emails exist).
|
||||
func (s *CredentialStore) MarkVerified(_ context.Context, email, verifiedAt string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.byMail[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if rec.VerifiedAt != "" {
|
||||
return true, nil
|
||||
}
|
||||
rec.VerifiedAt = verifiedAt
|
||||
s.byMail[key] = rec
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// UpdateHash replaces the stored password hash for email and persists the store.
|
||||
// The bool reports whether the email was known; an unknown email is not an error.
|
||||
func (s *CredentialStore) UpdateHash(_ context.Context, email, hash string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.byMail[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
rec.Hash = hash
|
||||
s.byMail[key] = rec
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// Delete removes the credential record for email and persists the store.
|
||||
// The bool reports whether the email was known.
|
||||
func (s *CredentialStore) Delete(_ context.Context, email string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byMail[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.byMail, key)
|
||||
return true, 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))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Purpose-scoped tokens (email verification, password reset) reuse the session
|
||||
// Signer's HMAC secret but bind a purpose + subject, so a token minted for one
|
||||
// flow cannot be replayed in another. Format:
|
||||
//
|
||||
// base64url(<purpose> NUL <subject> NUL <expUnix>) "." base64url(hmac)
|
||||
//
|
||||
// subject is the normalized email. Like session tokens these are signed, not
|
||||
// encrypted: they carry no secret, only a purpose, an email and an expiry, and
|
||||
// the HMAC makes them tamper-evident.
|
||||
const (
|
||||
purposeVerifyEmail = "verify"
|
||||
purposePasswordReset = "reset"
|
||||
)
|
||||
|
||||
// IssuePurpose returns a signed, purpose-bound token for subject that expires
|
||||
// after ttl.
|
||||
func (s *Signer) IssuePurpose(purpose, subject string, ttl time.Duration) string {
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
payload := purpose + "\x00" + subject + "\x00" + strconv.FormatInt(exp, 10)
|
||||
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return b + "." + s.sign(b)
|
||||
}
|
||||
|
||||
// ParsePurpose verifies a purpose token and returns its subject. It requires the
|
||||
// embedded purpose to match want and the token to be unexpired, returning
|
||||
// ErrInvalidToken for a bad signature/format/purpose and ErrExpiredToken when
|
||||
// the token has expired.
|
||||
func (s *Signer) ParsePurpose(want, token string) (string, error) {
|
||||
b, sig, ok := strings.Cut(token, ".")
|
||||
if !ok || b == "" || sig == "" {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(b)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
parts := strings.Split(string(raw), "\x00")
|
||||
if len(parts) != 3 || parts[0] != want {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
if time.Now().Unix() >= exp {
|
||||
return "", ErrExpiredToken
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
Reference in New Issue
Block a user