验证码使用成功后立即释放发送冷却并保留累计限额
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 23:11:46 +08:00
parent 0a6f20aa15
commit 6de0400cb4
6 changed files with 284 additions and 22 deletions

View File

@ -305,6 +305,11 @@ func TestEmailLoginAfterAsyncDelivery(t *testing.T) {
if code, _ := callLoginHandler(t, CompleteLoginVerification, verify); code == 0 {
t.Fatal("consumed code accepted again")
}
if _, err := currentSecurityRuntime().verification.Issue(verification.IssueRequest{
Purpose: verification.PurposePasswordChange, Account: user.Account, Email: "owner@example.net",
}, "127.0.0.1"); err != nil {
t.Fatalf("登录完成后其他业务无法发送验证码:%v", err)
}
}
func TestLoginMethodsRejectInvalidState(t *testing.T) {

View File

@ -0,0 +1,199 @@
package verification
import (
"errors"
"fmt"
"sync"
"testing"
"time"
)
func completionService(t *testing.T, persistent bool) (*Service, *string, *time.Time) {
t.Helper()
store, err := NewStore(64)
if err != nil {
t.Fatal(err)
}
var code string
service, err := NewService(store, func(_ Challenge, value string) error {
code = value
return nil
}, nil)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
service.now = func() time.Time { return now }
store.now = service.now
if persistent {
engine := newRateLimitEngine(t)
t.Cleanup(func() { _ = engine.Close() })
limiter, err := NewPersistentRateLimiter(engine)
if err != nil {
t.Fatal(err)
}
service.SetRateLimitStore(limiter)
}
return service, &code, &now
}
func completionRequest(purpose Purpose) IssueRequest {
return IssueRequest{Purpose: purpose, Account: "owner", Email: "owner@example.com",
UserID: 7, ResourceID: 9, BrowserTransaction: "browser", Environment: "production", CredentialVersion: 2}
}
func TestCompletionReleasesAllPurposeCooldownsAndPreservesQuota(t *testing.T) {
for _, persistent := range []bool{false, true} {
t.Run(fmt.Sprint(persistent), func(t *testing.T) {
service, code, _ := completionService(t, persistent)
purposes := []Purpose{PurposeLogin, PurposePasswordChange, PurposePasswordRecover, PurposeExternalEmailBind, PurposeExternalEmailChange}
for _, purpose := range purposes {
request := completionRequest(purpose)
challenge, err := service.Issue(request, "192.0.2.1")
if err != nil {
t.Fatalf("下一业务被已完成的验证阻塞:%s: %v", purpose, err)
}
if _, err := service.Issue(request, "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("未使用时应保留发送冷却:%v", err)
}
verify := func(value string) error {
return service.VerifyBoundWithContext(challenge.ID, request.Account, request.Email, value, purpose, request.ResourceID, "192.0.2.99", request.BrowserTransaction)
}
if err := verify("invalid"); !errors.Is(err, ErrInvalidChallenge) {
t.Fatal(err)
}
if _, err := service.Issue(request, "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("错误验证码释放了冷却:%v", err)
}
if err := verify(*code); err != nil {
t.Fatal(err)
}
if service.Store.Size() != 0 {
t.Fatal("验证码使用后仍有待验证状态")
}
if err := verify(*code); !errors.Is(err, ErrInvalidChallenge) {
t.Fatalf("已使用验证码被重放:%v", err)
}
}
if _, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("成功验证不应清除累计配额:%v", err)
}
})
}
}
func TestCompletionDoesNotReleaseNewerRequest(t *testing.T) {
for _, persistent := range []bool{false, true} {
t.Run(fmt.Sprint(persistent), func(t *testing.T) {
service, code, now := completionService(t, persistent)
first, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
firstCode := *code
*now = now.Add(issueInterval)
second, err := service.Issue(completionRequest(PurposePasswordChange), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
secondCode := *code
if err := service.VerifyBoundWithContext(first.ID, first.Account, first.Email, firstCode, first.Purpose, 9, "192.0.2.1", "browser"); err != nil {
t.Fatal(err)
}
if _, err := service.Issue(completionRequest(PurposeExternalEmailChange), "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("旧验证码清除了新请求的冷却:%v", err)
}
if err := service.VerifyBoundWithContext(second.ID, second.Account, second.Email, secondCode, second.Purpose, 9, "192.0.2.1", "browser"); err != nil {
t.Fatal(err)
}
if _, err := service.Issue(completionRequest(PurposeExternalEmailChange), "192.0.2.1"); err != nil {
t.Fatal(err)
}
})
}
}
func TestCompletionConcurrentVerificationAndPersistentRestart(t *testing.T) {
service, code, now := completionService(t, true)
challenge, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
results := make(chan error, 2)
var group sync.WaitGroup
for i := 0; i < 2; i++ {
group.Add(1)
go func() {
defer group.Done()
results <- service.VerifyBoundWithContext(challenge.ID, challenge.Account, challenge.Email, *code, challenge.Purpose, 9, "192.0.2.1", "browser")
}()
}
group.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
}
}
if success != 1 {
t.Fatalf("并发验证成功次数:%d", success)
}
restarted, _, _ := completionService(t, false)
restarted.now = func() time.Time { return *now }
restarted.SetRateLimitStore(service.RateLimits)
if _, err := restarted.Issue(completionRequest(PurposePasswordChange), "192.0.2.1"); err != nil {
t.Fatalf("重启后已释放的冷却仍阻塞:%v", err)
}
}
func TestCompletionKeepsDailyLimit(t *testing.T) {
for _, persistent := range []bool{false, true} {
t.Run(fmt.Sprint(persistent), func(t *testing.T) {
service, code, now := completionService(t, persistent)
for i := 0; i < maxPerDay; i++ {
if i == maxPerHour {
*now = now.Add(issueWindow)
}
challenge, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
if err := service.VerifyBoundWithContext(challenge.ID, challenge.Account, challenge.Email, *code, challenge.Purpose, 9, "192.0.2.1", "browser"); err != nil {
t.Fatal(err)
}
}
*now = now.Add(issueWindow)
if _, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("每日配额被清除:%v", err)
}
})
}
}
func TestCompletionCleanupFailureCanRetryWithoutCompletingBusiness(t *testing.T) {
service, code, _ := completionService(t, false)
failing := true
challenge, err := service.Store.issue(completionRequest(PurposeLogin), service.Send, func() error {
if failing {
return ErrRateLimitStoreUnavailable
}
return nil
})
if err != nil {
t.Fatal(err)
}
verify := func() error {
return service.VerifyBoundWithContext(challenge.ID, challenge.Account, challenge.Email, *code, challenge.Purpose, 9, "192.0.2.1", "browser")
}
if err := verify(); !errors.Is(err, ErrServiceDown) {
t.Fatalf("清理失败不应返回业务成功:%v", err)
}
failing = false
if err := verify(); err != nil {
t.Fatalf("清理恢复后无法重试:%v", err)
}
if err := verify(); !errors.Is(err, ErrInvalidChallenge) {
t.Fatalf("重试成功后仍可重放:%v", err)
}
}

View File

@ -64,8 +64,13 @@ func NewPersistentRateLimiter(engine *xorm.Engine) (*PersistentRateLimiter, erro
}
func (r *PersistentRateLimiter) Allow(ctx context.Context, request RateLimitRequest, now time.Time) (bool, error) {
allowed, _, err := r.allowWithRelease(ctx, request, now)
return allowed, err
}
func (r *PersistentRateLimiter) allowWithRelease(ctx context.Context, request RateLimitRequest, now time.Time) (bool, func() error, error) {
if r == nil || r.engine == nil {
return false, ErrRateLimitStoreUnavailable
return false, nil, ErrRateLimitStoreUnavailable
}
if ctx == nil {
ctx = context.Background()
@ -75,19 +80,20 @@ func (r *PersistentRateLimiter) Allow(ctx context.Context, request RateLimitRequ
}
dimensions := buildRateLimitDimensions(request)
if len(dimensions) == 0 {
return false, ErrRateLimitStoreUnavailable
return false, nil, ErrRateLimitStoreUnavailable
}
// SQLite 使用单连接和数据库锁,MySQL/PostgreSQL 通过行锁保证跨进程原子更新。
r.mu.Lock()
defer r.mu.Unlock()
session := r.engine.NewSession().Context(ctx)
defer session.Close()
if err := session.Begin(); err != nil {
return false, fmt.Errorf("begin verification rate limit transaction: %w", err)
return false, nil, fmt.Errorf("begin verification rate limit transaction: %w", err)
}
rollback := func(err error) (bool, error) {
rollback := func(err error) (bool, func() error, error) {
_ = session.Rollback()
return false, err
return false, nil, err
}
rows := make([]models.VerificationRateLimit, 0, len(dimensions))
for _, dimension := range dimensions {
@ -128,14 +134,49 @@ func (r *PersistentRateLimiter) Allow(ctx context.Context, request RateLimitRequ
row.LastIssuedAt = timePtr(now)
row.ExpiresAt = updated.Add(PersistentRateLimitStateRetention)
row.UpdatedAt = updated
rows[i] = row
if _, err := session.ID(row.ID).Cols("hour_started_at", "hour_count", "day_started_at", "day_count", "last_issued_at", "expires_at", "updated_at").Update(&row); err != nil {
return rollback(fmt.Errorf("update verification rate limit %s: %w", dimension.typ, err))
}
}
if err := session.Commit(); err != nil {
return false, fmt.Errorf("commit verification rate limit transaction: %w", err)
return false, nil, fmt.Errorf("commit verification rate limit transaction: %w", err)
}
return true, nil
return true, func() error { return r.releaseCooldown(rows) }, nil
}
func (r *PersistentRateLimiter) releaseCooldown(rows []models.VerificationRateLimit) error {
r.mu.Lock()
defer r.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
session := r.engine.NewSession().Context(ctx)
defer session.Close()
if err := session.Begin(); err != nil {
return err
}
defer session.Rollback()
for _, row := range rows {
// 同一维度已有新发送时,不清理新请求的冷却,也不重置小时和每日计数。
var current models.VerificationRateLimit
query := session.ID(row.ID)
if r.engine.DriverName() != "sqlite3" && r.engine.DriverName() != "sqlite" {
query = query.ForUpdate()
}
has, err := query.Get(&current)
if err != nil {
return err
}
if !has || current.LastIssuedAt == nil || current.HourCount != row.HourCount || current.DayCount != row.DayCount || current.LastIssuedAt.Unix() != row.LastIssuedAt.Unix() {
continue
}
_, err = session.Table(new(models.VerificationRateLimit)).ID(row.ID).
Update(map[string]interface{}{"last_issued_at": nil})
if err != nil {
return err
}
}
return session.Commit()
}
func (r *PersistentRateLimiter) Cleanup(ctx context.Context, now time.Time) (int, error) {

View File

@ -67,7 +67,7 @@ func (s *Service) Issue(request IssueRequest, remoteIP string) (Challenge, error
if request.Account == "" || request.Email == "" {
return Challenge{}, ErrInvalidChallenge
}
allowed, limitErr := s.allow(request, remoteIP)
allowed, release, limitErr := s.allow(request, remoteIP)
if limitErr != nil {
s.record(EventInput{Purpose: request.Purpose, Action: models.VerificationActionIssue, Outcome: models.VerificationOutcomeFailure, Account: request.Account, Email: request.Email, RemoteIP: remoteIP})
return Challenge{}, ErrServiceDown
@ -76,7 +76,7 @@ func (s *Service) Issue(request IssueRequest, remoteIP string) (Challenge, error
s.record(EventInput{Purpose: request.Purpose, Action: models.VerificationActionIssue, Outcome: models.VerificationOutcomeBlocked, Account: request.Account, Email: request.Email, RemoteIP: remoteIP})
return Challenge{}, ErrRateLimited
}
challenge, err := s.Store.Issue(request, s.Send)
challenge, err := s.Store.issue(request, s.Send, release)
if err != nil {
s.record(EventInput{Purpose: request.Purpose, Action: models.VerificationActionIssue, Outcome: models.VerificationOutcomeFailure, Account: request.Account, Email: request.Email, RemoteIP: remoteIP})
return Challenge{}, err
@ -122,21 +122,15 @@ func (s *Service) record(input EventInput) {
_ = s.Events.Record(input)
}
func (s *Service) allow(request IssueRequest, remoteIP string) (bool, error) {
func (s *Service) allow(request IssueRequest, remoteIP string) (bool, func() error, error) {
now := s.now()
if s.RateLimits != nil {
allowed, err := s.RateLimits.Allow(context.Background(), RateLimitRequest{
return s.RateLimits.allowWithRelease(context.Background(), RateLimitRequest{
Account: request.Account, Email: request.Email, RemoteIP: remoteIP,
UserID: request.UserID, Purpose: request.Purpose,
BrowserTransaction: request.BrowserTransaction,
Environment: request.Environment, CredentialVersion: request.CredentialVersion,
}, now)
if err != nil {
return false, err
}
if !allowed {
return false, nil
}
}
s.mu.Lock()
defer s.mu.Unlock()
@ -146,7 +140,7 @@ func (s *Service) allow(request IssueRequest, remoteIP string) (bool, error) {
}
if len(s.limits) > 10000 {
for key, value := range s.limits {
if now.Sub(value.last) > issueDaily {
if now.Sub(value.day) >= issueDaily {
delete(s.limits, key)
}
}
@ -154,9 +148,10 @@ func (s *Service) allow(request IssueRequest, remoteIP string) (bool, error) {
for _, key := range keys {
hourLimit, dayLimit := keyLimit(key)
if !counterAllowed(s.limits[key], now, hourLimit, dayLimit) {
return false, nil
return false, nil, nil
}
}
reserved := make(map[string]issueCounter, len(keys))
for _, key := range keys {
item := s.limits[key]
if item.hour.IsZero() || now.Sub(item.hour) >= issueWindow {
@ -171,8 +166,20 @@ func (s *Service) allow(request IssueRequest, remoteIP string) (bool, error) {
item.hourN++
item.dayN++
s.limits[key] = item
reserved[key] = item
}
return true, nil
return true, func() error {
s.mu.Lock()
defer s.mu.Unlock()
// 只释放本次发送的冷却,保留累计配额及后续发送的状态。
for key, expected := range reserved {
if item := s.limits[key]; item == expected {
item.last = time.Time{}
s.limits[key] = item
}
}
return nil
}, nil
}
func keyLimit(key string) (int, int) {

View File

@ -29,7 +29,7 @@ func TestServiceIssuesVerifiesAndAppliesMultiDimensionCooldown(t *testing.T) {
if err := service.Verify(challenge.ID, "smail", "user@example.com", code, PurposeLogin, "127.0.0.1"); err != nil {
t.Fatal(err)
}
if _, err := service.Issue(IssueRequest{Purpose: PurposeLogin, Account: "smail", Email: "user@example.com"}, "127.0.0.1"); !errors.Is(err, ErrRateLimited) {
if _, err := service.Issue(IssueRequest{Purpose: PurposeLogin, Account: "smail", Email: "user@example.com"}, "127.0.0.1"); err != nil {
t.Fatalf("second issue error = %v", err)
}
}

View File

@ -67,6 +67,7 @@ type IssueRequest struct {
type Delivery func(challenge Challenge, code string) error
type entry struct {
onVerified func() error
challenge Challenge
mac [32]byte
browserContextDigest [32]byte
@ -107,6 +108,10 @@ func NewStore(maxEntries int) (*Store, error) {
}
func (s *Store) Issue(request IssueRequest, delivery Delivery) (Challenge, error) {
return s.issue(request, delivery, nil)
}
func (s *Store) issue(request IssueRequest, delivery Delivery, onVerified func() error) (Challenge, error) {
if s == nil || delivery == nil || !IsSupportedPurpose(request.Purpose) || strings.TrimSpace(request.Account) == "" || strings.TrimSpace(request.Email) == "" {
return Challenge{}, ErrInvalidChallenge
}
@ -169,7 +174,7 @@ func (s *Store) Issue(request IssueRequest, delivery Delivery) (Challenge, error
_, _ = mac.Write([]byte(code))
var digest [32]byte
copy(digest[:], mac.Sum(nil))
s.entries[challenge.ID] = entry{challenge: challenge, mac: digest, browserContextDigest: browserContextDigest, hasBrowserContextDigest: browserContext != ""}
s.entries[challenge.ID] = entry{challenge: challenge, mac: digest, browserContextDigest: browserContextDigest, hasBrowserContextDigest: browserContext != "", onVerified: onVerified}
s.active[activeKey] = challenge.ID
s.perUser[account]++
s.mu.Unlock()
@ -252,6 +257,11 @@ func (s *Store) VerifyBoundWithContext(id, account, email, code string, purpose
s.entries[id] = item
return ErrInvalidChallenge
}
if item.onVerified != nil {
if err := item.onVerified(); err != nil {
return ErrServiceDown
}
}
s.deleteLocked(id)
return nil
}