NextReceipt/internal/auth/otp.go
cclohmar f0c576c543 feat: transform NextExpense into NextReceipt — receipt saver for warranty & returns
- Replace events+expenses with flat purchases table
- Add warranty_months, return_days, product_name fields
- Remove currency conversion, CSV/PDF reporting, event filing
- Simplify auth (no onboarding/department/profile)
- Update AI extraction prompts for product/warranty info
- Update all branding: templates, install.sh, Makefile, service file
2026-06-21 18:57:29 +00:00

105 lines
2.9 KiB
Go

// 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)
}