448 lines
12 KiB
Go
448 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)
|
||
// #nosec G602 -- digit 取值由 int(value)%len(digits) 限定在 0-9 范围,且 counts 和 digits 长度均固定为 10,不存在切片越界
|
||
code[written] = digits[digit]
|
||
// #nosec G602 -- digit 取值限定在 0-9 范围,counts 长度为 10,不存在切片越界
|
||
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 = ""
|
||
}
|