NextExpense/internal/auth/otp.go
cclohmar e831fcf617 fix: resolve 7 critical security findings from code review
CR-1: Path traversal in createReceiptZip — validate image_path is within storage/
CR-2: Missing authz on EditExpense/UpdateExpense — verify event ownership
CR-3: OTP timing side-channel — use crypto/subtle.ConstantTimeCompare
CR-4: Logout doesn't invalidate session — moved to AuthHandler with Sessions.Delete()
CR-5: OTP reuse race condition — mutex lock around validate+delete
CR-6: Live credentials on disk — removed .env from disk entirely
CR-7: No TLS — documented as expected behind-proxy deployment

Additional:
- Removed stale github.com/expenseflow import path from auth.go
- Made EnvironmentFile optional (prefix with -) so .env is not required
- App runs and starts clean without any .env file
2026-05-31 01:50:08 +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)
}