feat(captcha): in-process nonce store for single-use enforcement

This commit is contained in:
Alex Dunmow 2026-07-03 17:16:19 +08:00
parent f5cbb56df7
commit 7b15ce70d0
2 changed files with 66 additions and 0 deletions

39
captcha/nonce.go Normal file
View File

@ -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
}

27
captcha/nonce_test.go Normal file
View File

@ -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")
}
}