Smail/backend/services/verification/service.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

212 lines
6.6 KiB
Go

package verification
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/models"
)
var (
ErrRateLimited = errors.New("verification request rate limited")
ErrServiceDown = errors.New("verification service unavailable")
)
const (
issueInterval = time.Minute
issueWindow = time.Hour
issueDaily = 24 * time.Hour
maxPerHour = 5
maxPerDay = 10
maxPerIPHour = 20
maxPerIPDay = 20
)
type Service struct {
Store *Store
Events *EventStore
Send Delivery
RateLimits *PersistentRateLimiter
mu sync.Mutex
limits map[string]issueCounter
now func() time.Time
}
type issueCounter struct {
last time.Time
hour time.Time
hourN int
day time.Time
dayN int
}
func NewService(store *Store, send Delivery, events *EventStore) (*Service, error) {
if store == nil || send == nil {
return nil, ErrServiceDown
}
return &Service{Store: store, Send: send, Events: events, limits: make(map[string]issueCounter), now: time.Now}, nil
}
func (s *Service) SetRateLimitStore(store *PersistentRateLimiter) {
if s == nil {
return
}
s.RateLimits = store
}
func (s *Service) Issue(request IssueRequest, remoteIP string) (Challenge, error) {
if s == nil || s.Store == nil || s.Send == nil || !IsSupportedPurpose(request.Purpose) {
return Challenge{}, ErrServiceDown
}
request.Account = strings.TrimSpace(request.Account)
request.Email = strings.ToLower(strings.TrimSpace(request.Email))
if request.Account == "" || request.Email == "" {
return Challenge{}, ErrInvalidChallenge
}
allowed, 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
}
if !allowed {
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)
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
}
s.record(EventInput{Purpose: request.Purpose, Action: models.VerificationActionIssue, Outcome: models.VerificationOutcomeSuccess, Account: request.Account, Email: request.Email, RemoteIP: remoteIP})
return challenge, nil
}
func (s *Service) Verify(id, account, email, code string, purpose Purpose, remoteIP string) error {
return s.VerifyBound(id, account, email, code, purpose, 0, remoteIP)
}
func (s *Service) VerifyWithContext(id, account, email, code string, purpose Purpose, remoteIP, browserContext string) error {
return s.verifyBound(id, account, email, code, purpose, 0, remoteIP, browserContext)
}
// VerifyBound 验证绑定到具体业务资源的验证码,避免跨资源复用挑战。
func (s *Service) VerifyBound(id, account, email, code string, purpose Purpose, resourceID int, remoteIP string) error {
return s.verifyBound(id, account, email, code, purpose, resourceID, remoteIP, "")
}
func (s *Service) VerifyBoundWithContext(id, account, email, code string, purpose Purpose, resourceID int, remoteIP, browserContext string) error {
return s.verifyBound(id, account, email, code, purpose, resourceID, remoteIP, browserContext)
}
func (s *Service) verifyBound(id, account, email, code string, purpose Purpose, resourceID int, remoteIP, browserContext string) error {
if s == nil || s.Store == nil || !IsSupportedPurpose(purpose) {
return ErrServiceDown
}
err := s.Store.VerifyBoundWithContext(id, account, email, code, purpose, resourceID, browserContext)
outcome := models.VerificationOutcomeSuccess
if err != nil {
outcome = models.VerificationOutcomeFailure
}
s.record(EventInput{Purpose: purpose, Action: models.VerificationActionVerify, Outcome: outcome, Account: account, Email: email, RemoteIP: remoteIP})
return err
}
func (s *Service) record(input EventInput) {
if s == nil || s.Events == nil {
return
}
_ = s.Events.Record(input)
}
func (s *Service) allow(request IssueRequest, remoteIP string) (bool, error) {
now := s.now()
if s.RateLimits != nil {
allowed, err := s.RateLimits.Allow(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()
keys := []string{"account:" + strings.ToLower(strings.TrimSpace(request.Account)), "email:" + strings.ToLower(strings.TrimSpace(request.Email))}
if remoteIP = strings.TrimSpace(remoteIP); remoteIP != "" {
keys = append(keys, "ip:"+remoteIP)
}
if len(s.limits) > 10000 {
for key, value := range s.limits {
if now.Sub(value.last) > issueDaily {
delete(s.limits, key)
}
}
}
for _, key := range keys {
hourLimit, dayLimit := keyLimit(key)
if !counterAllowed(s.limits[key], now, hourLimit, dayLimit) {
return false, nil
}
}
for _, key := range keys {
item := s.limits[key]
if item.hour.IsZero() || now.Sub(item.hour) >= issueWindow {
item.hour = now
item.hourN = 0
}
if item.day.IsZero() || now.Sub(item.day) >= issueDaily {
item.day = now
item.dayN = 0
}
item.last = now
item.hourN++
item.dayN++
s.limits[key] = item
}
return true, nil
}
func keyLimit(key string) (int, int) {
if strings.HasPrefix(key, "ip:") {
return maxPerIPHour, maxPerIPDay
}
return maxPerHour, maxPerDay
}
func counterAllowed(item issueCounter, now time.Time, limits ...int) bool {
hourLimit, dayLimit := maxPerHour, maxPerDay
if len(limits) > 0 && limits[0] > 0 {
hourLimit = limits[0]
}
if len(limits) > 1 && limits[1] > 0 {
dayLimit = limits[1]
}
if !item.last.IsZero() && now.Sub(item.last) < issueInterval {
return false
}
hourN := item.hourN
dayN := item.dayN
if item.hour.IsZero() || now.Sub(item.hour) >= issueWindow {
hourN = 0
}
if item.day.IsZero() || now.Sub(item.day) >= issueDaily {
dayN = 0
}
return hourN < hourLimit && dayN < dayLimit
}
func (s *Service) String() string {
if s == nil {
return ""
}
return fmt.Sprintf("verification service with %d active rate limit keys", len(s.limits))
}