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:". 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) } }