// Package auth provides authentication utilities including OTP generation and // validation, as well as failure tracking for rate-limiting attempts. package auth import ( "crypto/rand" "crypto/subtle" "fmt" "sync" "time" ) // GenerateOTP generates a 6-digit numeric OTP code using crypto/rand. // Each digit is derived by reading a random byte and computing modulo 10, // producing a uniformly distributed digit 0-9. The result is zero-padded // to always return exactly 6 characters. func GenerateOTP() (string, error) { bytes := make([]byte, 6) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("failed to generate OTP: %w", err) } code := make([]byte, 6) for i, b := range bytes { code[i] = byte(b%10) + '0' } return string(code), nil } // ValidateOTP validates a provided OTP against a stored code with an // expiration check. Returns false if the current time is past expiresAt // or if the codes do not match. func ValidateOTP(provided, stored string, expiresAt time.Time) bool { if time.Now().After(expiresAt) { return false } // Use constant-time comparison to prevent timing side-channel attacks. return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1 } // attemptData stores the failure count and timestamp for a single email. type attemptData struct { count int lastAttempt time.Time } // FailureTracker tracks consecutive failed OTP verification attempts per // email, implementing a 3-attempt lockout window. type FailureTracker struct { mu sync.Mutex attempts map[string]*attemptData } // NewFailureTracker creates and returns a new FailureTracker with an empty // attempts map. func NewFailureTracker() *FailureTracker { return &FailureTracker{ attempts: make(map[string]*attemptData), } } // RecordFailure increments the failure count for the given email and records // the current time as the last attempt. Once the count reaches 3, the email // becomes locked out until the lockout window expires. func (ft *FailureTracker) RecordFailure(email string) { ft.mu.Lock() defer ft.mu.Unlock() data, exists := ft.attempts[email] if !exists { data = &attemptData{} ft.attempts[email] = data } data.count++ data.lastAttempt = time.Now() } // IsLockedOut returns true if the email has 3 or more recorded failures // within the last 1 minute. Returns false if the email has no failures, // fewer than 3 failures, or if the last failure was more than 1 minute ago. func (ft *FailureTracker) IsLockedOut(email string) bool { ft.mu.Lock() defer ft.mu.Unlock() data, exists := ft.attempts[email] if !exists { return false } if data.count < 3 { return false } if time.Since(data.lastAttempt) > time.Minute { return false } return true } // Reset clears the failure tracking data for the given email. This should // be called upon successful OTP verification to allow fresh attempts. func (ft *FailureTracker) Reset(email string) { ft.mu.Lock() defer ft.mu.Unlock() delete(ft.attempts, email) }