- 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
103 lines
2.8 KiB
Go
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)
|
|
}
|