inboxer/internal/auth/otp.go
cclohmar ca970104ee chore: initial commit — ExpenseFlow AI-Powered Expense Tracker
- Passwordless email OTP authentication
- Event-based expense tracking with HTMX UI
- AI receipt extraction via DeepSeek Vision API
- CSV/PDF report generation with email filing
- PWA with service worker and manifest
- Mobile-first responsive design
- SQLite database with auto-migration
2026-05-29 19:43:30 +00:00

103 lines
2.8 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"
"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
}
return provided == stored
}
// 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)
}