Files
go-cart-actor/internal/customerauth/redis.go
T
mats 528c59bfd3
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
cart and checkout
2026-06-27 19:49:00 +02:00

216 lines
7.1 KiB
Go

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)
}
}