216 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|