package repository import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) const customerLoginAttemptKeyPrefix = "customer_login:attempts:" // CustomerLoginAttemptRepository counts customer login attempts per phone number, so a // password cannot be guessed by trying many. type CustomerLoginAttemptRepository interface { // Hit counts one attempt for the phone number. It returns the attempts counted in // the current window, this one included, and how long the window still runs. A // window starts at the first attempt after the previous one ended. Hit(ctx context.Context, phoneNumber string, window time.Duration) (int64, time.Duration, error) // Reset forgets the attempts of the phone number. Reset(ctx context.Context, phoneNumber string) error } type customerLoginAttemptRepository struct { client *redis.Client } func NewCustomerLoginAttemptRepository(client *redis.Client) CustomerLoginAttemptRepository { return &customerLoginAttemptRepository{client: client} } func (r *customerLoginAttemptRepository) Hit(ctx context.Context, phoneNumber string, window time.Duration) (int64, time.Duration, error) { key := customerLoginAttemptKeyPrefix + phoneNumber // One transaction, so the key never exists without its expiry: concurrent attempts // all count, and a crash cannot leave a number locked for good. pipe := r.client.TxPipeline() pipe.SetNX(ctx, key, 0, window) count := pipe.Incr(ctx, key) left := pipe.PTTL(ctx, key) if _, err := pipe.Exec(ctx); err != nil { return 0, 0, fmt.Errorf("count login attempt: %w", err) } return count.Val(), left.Val(), nil } func (r *customerLoginAttemptRepository) Reset(ctx context.Context, phoneNumber string) error { if err := r.client.Del(ctx, customerLoginAttemptKeyPrefix+phoneNumber).Err(); err != nil { return fmt.Errorf("reset login attempts: %w", err) } return nil }