Smail/backend/services/verification/store.go
smail 398824e8b4
Some checks are pending
Security Checks / security (push) Waiting to run
优化验证码生成:同一数字最多出现两次
2026-10-02 17:32:07 +08:00

446 lines
12 KiB
Go

package verification
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
)
var (
ErrInvalidChallenge = errors.New("verification challenge is invalid")
ErrExpiredChallenge = errors.New("verification challenge is expired")
ErrTooManyAttempts = errors.New("verification challenge attempts exceeded")
ErrCapacityExceeded = errors.New("verification challenge capacity exceeded")
)
const (
DefaultCodeLength = 8
DefaultTTL = 10 * time.Minute
DefaultMaxEntries = 10000
DefaultMaxPerUser = 4
DefaultMaxAttempts = 5
)
type Purpose string
const (
PurposeLogin Purpose = "login"
PurposePasswordRecover Purpose = "password_recover"
PurposePasswordChange Purpose = "password_change"
PurposeAdminLocalReset Purpose = "admin_local_reset"
PurposeExternalEmailBind Purpose = "external_email_bind"
PurposeExternalEmailChange Purpose = "external_email_change"
)
type Challenge struct {
ID string
Purpose Purpose
Account string
Email string
ResourceID int
IssuedAt time.Time
ExpiresAt time.Time
Attempts int
MaxAttempts int
}
type IssueRequest struct {
Purpose Purpose
Account string
Email string
UserID int
BrowserTransaction string
Environment string
CredentialVersion int
ResourceID int
TTL time.Duration
MaxAttempts int
}
type Delivery func(challenge Challenge, code string) error
type entry struct {
onVerified func() error
challenge Challenge
mac [32]byte
browserContextDigest [32]byte
hasBrowserContextDigest bool
}
type Store struct {
mu sync.Mutex
key [32]byte
entries map[string]entry
active map[string]string
perUser map[string]int
maxEntries int
maxPerUser int
codeLength int
maxAttempts int
now func() time.Time
}
func NewStore(maxEntries int) (*Store, error) {
if maxEntries <= 0 {
maxEntries = DefaultMaxEntries
}
store := &Store{
entries: make(map[string]entry),
active: make(map[string]string),
perUser: make(map[string]int),
maxEntries: maxEntries,
maxPerUser: DefaultMaxPerUser,
codeLength: DefaultCodeLength,
maxAttempts: DefaultMaxAttempts,
now: time.Now,
}
if _, err := rand.Read(store.key[:]); err != nil {
return nil, fmt.Errorf("generate verification process key: %w", err)
}
return store, nil
}
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
}
if request.TTL <= 0 {
request.TTL = DefaultTTL
}
if request.MaxAttempts <= 0 || request.MaxAttempts > s.maxAttempts {
request.MaxAttempts = s.maxAttempts
}
now := s.now()
code, err := randomCode(s.codeLength)
if err != nil {
return Challenge{}, err
}
defer clearString(&code)
id, err := randomID()
if err != nil {
return Challenge{}, err
}
account := strings.TrimSpace(request.Account)
email := strings.TrimSpace(strings.ToLower(request.Email))
browserContext := strings.TrimSpace(request.BrowserTransaction)
var browserContextDigest [32]byte
if browserContext != "" {
browserContextDigest = sha256.Sum256([]byte(browserContext))
}
challenge := Challenge{
ID: id,
Purpose: request.Purpose,
Account: account,
Email: email,
ResourceID: request.ResourceID,
IssuedAt: now,
ExpiresAt: now.Add(request.TTL),
MaxAttempts: request.MaxAttempts,
}
s.mu.Lock()
s.cleanupLocked(now)
activeKey := challengeKey(account, request.Purpose)
if previousID := s.active[activeKey]; previousID != "" {
s.deleteLocked(previousID)
}
if len(s.entries) >= s.maxEntries || s.perUser[account] >= s.maxPerUser {
s.mu.Unlock()
return Challenge{}, ErrCapacityExceeded
}
mac := hmac.New(sha256.New, s.key[:])
_, _ = mac.Write([]byte(challenge.ID))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(challenge.Purpose))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(challenge.Account))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(challenge.Email))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(fmt.Sprintf("%d", challenge.ResourceID)))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write(browserContextDigest[:])
_, _ = mac.Write([]byte{0})
_, _ = 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 != "", onVerified: onVerified}
s.active[activeKey] = challenge.ID
s.perUser[account]++
s.mu.Unlock()
if err := delivery(challenge, code); err != nil {
s.mu.Lock()
s.deleteLocked(challenge.ID)
s.mu.Unlock()
return Challenge{}, err
}
return challenge, nil
}
// IsSupportedPurpose 保证验证码只能用于已确认的业务流程。
func IsSupportedPurpose(purpose Purpose) bool {
switch purpose {
case PurposeLogin, PurposePasswordRecover, PurposePasswordChange, PurposeExternalEmailBind, PurposeExternalEmailChange, PurposeAdminLocalReset:
return true
default:
return false
}
}
func (s *Store) Verify(id, account, email, code string, purpose Purpose) error {
return s.VerifyBound(id, account, email, code, purpose, 0)
}
// VerifyBound 验证并原子消费绑定到具体业务资源的验证码。
func (s *Store) VerifyBound(id, account, email, code string, purpose Purpose, resourceID int) error {
return s.VerifyBoundWithContext(id, account, email, code, purpose, resourceID, "")
}
func (s *Store) VerifyBoundWithContext(id, account, email, code string, purpose Purpose, resourceID int, browserContext string) error {
if s == nil || strings.TrimSpace(id) == "" || strings.TrimSpace(account) == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(code) == "" || purpose == "" {
return ErrInvalidChallenge
}
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupLocked(now)
item, ok := s.entries[id]
if !ok || item.challenge.Purpose != purpose || item.challenge.Account != strings.TrimSpace(account) || item.challenge.Email != strings.TrimSpace(strings.ToLower(email)) || item.challenge.ResourceID != resourceID {
return ErrInvalidChallenge
}
if item.hasBrowserContextDigest {
providedDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))
if strings.TrimSpace(browserContext) == "" || subtle.ConstantTimeCompare(providedDigest[:], item.browserContextDigest[:]) != 1 {
return ErrInvalidChallenge
}
}
if !now.Before(item.challenge.ExpiresAt) {
s.deleteLocked(id)
return ErrExpiredChallenge
}
item.challenge.Attempts++
if item.challenge.Attempts > item.challenge.MaxAttempts {
s.deleteLocked(id)
return ErrTooManyAttempts
}
mac := hmac.New(sha256.New, s.key[:])
_, _ = mac.Write([]byte(item.challenge.ID))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(item.challenge.Purpose))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(item.challenge.Account))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(item.challenge.Email))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(fmt.Sprintf("%d", item.challenge.ResourceID)))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write(item.browserContextDigest[:])
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(code))
actual := mac.Sum(nil)
if subtle.ConstantTimeCompare(actual, item.mac[:]) != 1 {
if item.challenge.Attempts >= item.challenge.MaxAttempts {
s.deleteLocked(id)
return ErrTooManyAttempts
}
s.entries[id] = item
return ErrInvalidChallenge
}
if item.onVerified != nil {
if err := item.onVerified(); err != nil {
return ErrServiceDown
}
}
s.deleteLocked(id)
return nil
}
func (s *Store) Cleanup() int {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
before := len(s.entries)
s.cleanupLocked(s.now())
return before - len(s.entries)
}
func (s *Store) ResetForProcessRestart() {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
_, _ = rand.Read(s.key[:])
s.entries = make(map[string]entry)
s.active = make(map[string]string)
s.perUser = make(map[string]int)
}
// InvalidateAccount 删除指定账号尚未完成的全部验证码挑战。
func (s *Store) InvalidateAccount(account string) int {
if s == nil {
return 0
}
account = strings.TrimSpace(account)
if account == "" {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
removed := 0
for id, item := range s.entries {
if item.challenge.Account == account {
s.deleteLocked(id)
removed++
}
}
return removed
}
// InvalidateResource 删除绑定到指定业务资源的全部未完成挑战。
func (s *Store) InvalidateResource(resourceID int, purpose Purpose) int {
if s == nil || resourceID <= 0 {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
removed := 0
for id, item := range s.entries {
if item.challenge.ResourceID == resourceID && (purpose == "" || item.challenge.Purpose == purpose) {
s.deleteLocked(id)
removed++
}
}
return removed
}
// InvalidateChallenge 删除指定的未完成挑战。
func (s *Store) InvalidateChallenge(id string) bool {
if s == nil || strings.TrimSpace(id) == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.entries[id]; !ok {
return false
}
s.deleteLocked(id)
return true
}
func (s *Store) Size() int {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
return len(s.entries)
}
func (s *Store) cleanupLocked(now time.Time) {
for id, item := range s.entries {
if !now.Before(item.challenge.ExpiresAt) {
s.deleteLocked(id)
}
}
}
func randomCode(length int) (string, error) {
return randomCodeFromReader(length, rand.Reader)
}
func randomCodeFromReader(length int, reader io.Reader) (string, error) {
if length <= 0 || length > 12 {
return "", ErrInvalidChallenge
}
const digits = "0123456789"
code := make([]byte, length)
random := make([]byte, length*2)
defer clearBytes(random)
defer clearBytes(code)
for {
var counts [10]uint8
for written := 0; written < length; {
if _, err := io.ReadFull(reader, random); err != nil {
return "", err
}
for _, value := range random {
if value >= 250 {
continue
}
digit := int(value) % len(digits)
code[written] = digits[digit]
counts[digit]++
written++
if written == length {
break
}
}
}
valid := true
for _, count := range counts {
if count > 2 {
valid = false
break
}
}
if valid {
return string(code), nil
}
}
}
func randomID() (string, error) {
var raw [16]byte
if _, err := io.ReadFull(rand.Reader, raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
func challengeKey(account string, purpose Purpose) string {
return account + "\x00" + string(purpose)
}
func (s *Store) deleteLocked(id string) {
item, ok := s.entries[id]
if !ok {
return
}
delete(s.entries, id)
key := challengeKey(item.challenge.Account, item.challenge.Purpose)
if s.active[key] == id {
delete(s.active, key)
}
if count := s.perUser[item.challenge.Account]; count <= 1 {
delete(s.perUser, item.challenge.Account)
} else {
s.perUser[item.challenge.Account] = count - 1
}
for index := range item.mac {
item.mac[index] = 0
}
}
func clearString(value *string) {
if value == nil {
return
}
buffer := []byte(*value)
clearBytes(buffer)
*value = ""
}