// Package sendemailcodeabuse implements ports.SendEmailCodeAbuseProtector with // one Redis TTL key per normalized e-mail address. package sendemailcodeabuse import ( "context" "crypto/tls" "encoding/base64" "errors" "fmt" "strings" "time" "galaxy/authsession/internal/domain/challenge" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/ports" "github.com/redis/go-redis/v9" ) // Config configures one Redis-backed send-email-code abuse protector. type Config struct { // Addr is the Redis network address in host:port form. Addr string // Username is the optional Redis ACL username. Username string // Password is the optional Redis ACL password. Password string // DB is the Redis logical database index. DB int // TLSEnabled enables TLS with a conservative minimum protocol version. TLSEnabled bool // KeyPrefix is the namespace prefix applied to every resend-throttle key. KeyPrefix string // OperationTimeout bounds each Redis round trip performed by the adapter. OperationTimeout time.Duration } // Protector applies the fixed resend cooldown with one Redis key per // normalized e-mail address. type Protector struct { client *redis.Client keyPrefix string operationTimeout time.Duration } // New constructs a Redis-backed resend-throttle protector from cfg. func New(cfg Config) (*Protector, error) { switch { case strings.TrimSpace(cfg.Addr) == "": return nil, errors.New("new redis send email code abuse protector: redis addr must not be empty") case cfg.DB < 0: return nil, errors.New("new redis send email code abuse protector: redis db must not be negative") case strings.TrimSpace(cfg.KeyPrefix) == "": return nil, errors.New("new redis send email code abuse protector: redis key prefix must not be empty") case cfg.OperationTimeout <= 0: return nil, errors.New("new redis send email code abuse protector: operation timeout must be positive") } options := &redis.Options{ Addr: cfg.Addr, Username: cfg.Username, Password: cfg.Password, DB: cfg.DB, Protocol: 2, DisableIdentity: true, } if cfg.TLSEnabled { options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} } return &Protector{ client: redis.NewClient(options), keyPrefix: cfg.KeyPrefix, operationTimeout: cfg.OperationTimeout, }, nil } // Close releases the underlying Redis client resources. func (p *Protector) Close() error { if p == nil || p.client == nil { return nil } return p.client.Close() } // Ping verifies that the configured Redis backend is reachable within the // adapter operation timeout budget. func (p *Protector) Ping(ctx context.Context) error { operationCtx, cancel, err := p.operationContext(ctx, "ping redis send email code abuse protector") if err != nil { return err } defer cancel() if err := p.client.Ping(operationCtx).Err(); err != nil { return fmt.Errorf("ping redis send email code abuse protector: %w", err) } return nil } // CheckAndReserve applies the fixed resend cooldown using one TTL key per // normalized e-mail address. func (p *Protector) CheckAndReserve(ctx context.Context, input ports.SendEmailCodeAbuseInput) (ports.SendEmailCodeAbuseResult, error) { if err := input.Validate(); err != nil { return ports.SendEmailCodeAbuseResult{}, fmt.Errorf("check and reserve send email code abuse: %w", err) } operationCtx, cancel, err := p.operationContext(ctx, "check and reserve send email code abuse") if err != nil { return ports.SendEmailCodeAbuseResult{}, err } defer cancel() key := p.lookupKey(input.Email) value := input.Now.UTC().Add(challenge.ResendThrottleCooldown).Format(time.RFC3339Nano) created, err := p.client.SetNX(operationCtx, key, value, challenge.ResendThrottleCooldown).Result() if err != nil { return ports.SendEmailCodeAbuseResult{}, fmt.Errorf("check and reserve send email code abuse for %q: %w", input.Email, err) } if created { return ports.SendEmailCodeAbuseResult{Outcome: ports.SendEmailCodeAbuseOutcomeAllowed}, nil } return ports.SendEmailCodeAbuseResult{Outcome: ports.SendEmailCodeAbuseOutcomeThrottled}, nil } func (p *Protector) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) { if p == nil || p.client == nil { return nil, nil, fmt.Errorf("%s: nil protector", operation) } if ctx == nil { return nil, nil, fmt.Errorf("%s: nil context", operation) } operationCtx, cancel := context.WithTimeout(ctx, p.operationTimeout) return operationCtx, cancel, nil } func (p *Protector) lookupKey(email common.Email) string { return p.keyPrefix + base64.RawURLEncoding.EncodeToString([]byte(email.String())) } var _ ports.SendEmailCodeAbuseProtector = (*Protector)(nil)