- 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
105 lines
2.9 KiB
Go
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)
|
|
}
|