diff --git a/captcha/nonce.go b/captcha/nonce.go new file mode 100644 index 0000000..1605c8a --- /dev/null +++ b/captcha/nonce.go @@ -0,0 +1,39 @@ +package captcha + +import ( + "sync" + "time" +) + +// NonceStore tracks used challenge nonces for single-use enforcement. The +// default MemoryNonceStore is per-process (no DB) — sufficient for the 5a +// model. A durable cross-process store (5b) can implement this interface later. +type NonceStore interface { + // MarkUsed records nonce; returns true on first use, false if already seen. + MarkUsed(nonce string, ttl time.Duration) bool +} + +type MemoryNonceStore struct { + mu sync.Mutex + used map[string]time.Time +} + +func NewMemoryNonceStore() *MemoryNonceStore { + return &MemoryNonceStore{used: make(map[string]time.Time)} +} + +func (m *MemoryNonceStore) MarkUsed(nonce string, ttl time.Duration) bool { + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + for k, exp := range m.used { + if !exp.After(now) { + delete(m.used, k) + } + } + if _, seen := m.used[nonce]; seen { + return false + } + m.used[nonce] = now.Add(ttl) + return true +} diff --git a/captcha/nonce_test.go b/captcha/nonce_test.go new file mode 100644 index 0000000..d7ec502 --- /dev/null +++ b/captcha/nonce_test.go @@ -0,0 +1,27 @@ +package captcha + +import ( + "testing" + "time" +) + +func TestMemoryNonceStoreSingleUse(t *testing.T) { + st := NewMemoryNonceStore() + if !st.MarkUsed("n1", time.Minute) { + t.Fatal("first use should return true") + } + if st.MarkUsed("n1", time.Minute) { + t.Fatal("second use should return false") + } + if !st.MarkUsed("n2", time.Minute) { + t.Fatal("distinct nonce should return true") + } +} + +func TestMemoryNonceStoreExpiry(t *testing.T) { + st := NewMemoryNonceStore() + st.MarkUsed("n1", -time.Second) // already expired + if !st.MarkUsed("n1", time.Minute) { + t.Fatal("expired nonce should be reusable") + } +}