Smail/backend/services/recovery/email_step.go
smail b4f5f51c0e
Some checks are pending
Security Checks / security (push) Waiting to run
保存认证容量保护整改检查点待远程复审
2026-09-17 12:00:45 +08:00

147 lines
4.8 KiB
Go

package recovery
import (
"crypto/sha256"
"crypto/subtle"
"errors"
"strings"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/password"
)
const maxEmailContinuationAttempts = 5
// EmailVerificationResult 仅在邮箱核验通过后告知是否需要管理员附加验证。
type EmailVerificationResult struct {
Ticket string `json:"ticket,omitempty"`
RequiresRecoveryCode bool `json:"requires_recovery_code,omitempty"`
Continuation string `json:"continuation,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
}
// 中间凭证与重置票据分开存储,不能用于直接修改密码。
type emailContinuation struct {
ticket
Account string
SecurityVersion int
Attempts int
Busy bool
}
func (s *Service) VerifyEmailStepWithContext(challengeID, account, email, code, remoteIP, browserContext string) (EmailVerificationResult, error) {
if strings.TrimSpace(browserContext) == "" {
return EmailVerificationResult{}, ErrRecoveryDenied
}
user, binding, err := s.verifyEmailIdentity(challengeID, account, email, code, remoteIP, browserContext)
if err != nil {
return EmailVerificationResult{}, err
}
if user.EffectiveRole() != access.RoleAdmin {
id, err := s.issueTicketFor(user.ID, user.SessionVersion, "email", binding.ID, binding.CredentialVersion, browserContext)
return EmailVerificationResult{Ticket: id}, err
}
id, err := newTicketID()
if err != nil {
return EmailVerificationResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.cleanupLocked(now)
if len(s.emailPending) >= maxRecoveryTickets {
return EmailVerificationResult{}, ErrUnavailable
}
s.emailPending[ticketKey(id)] = &emailContinuation{
ticket: ticket{
UserID: user.ID, SessionVersion: user.SessionVersion,
CredentialID: binding.ID, CredentialVersion: binding.CredentialVersion,
BrowserContextDigest: sha256.Sum256([]byte(strings.TrimSpace(browserContext))),
HasBrowserContextDigest: true, ExpiresAt: now.Add(authorizationTTL),
},
Account: user.Account, SecurityVersion: user.SecurityVersion,
}
return EmailVerificationResult{
RequiresRecoveryCode: true, Continuation: id, ExpiresIn: int(authorizationTTL.Seconds()),
}, nil
}
func (s *Service) ContinueEmailWithContext(continuation, identifier, email, recoveryCode, browserContext string) (string, error) {
defer clearString(&recoveryCode)
if s == nil || s.engine == nil || s.credentials == nil || s.external == nil {
return "", ErrUnavailable
}
account, err := auth.NormalizeIdentifier(identifier)
if err != nil || strings.TrimSpace(continuation) == "" || strings.TrimSpace(browserContext) == "" {
return "", ErrRecoveryDenied
}
key := ticketKey(continuation)
contextDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))
s.mu.Lock()
s.cleanupLocked(s.now())
pending, ok := s.emailPending[key]
if !ok {
s.mu.Unlock()
return "", ErrRecoveryExpired
}
if pending.Busy || !strings.EqualFold(account, pending.Account) || subtle.ConstantTimeCompare(contextDigest[:], pending.BrowserContextDigest[:]) != 1 {
s.mu.Unlock()
return "", ErrRecoveryDenied
}
pending.Attempts++
pending.Busy = true
s.mu.Unlock()
// 数据库和恢复码哈希验证不占用全局锁;同一凭证同时只能有一个请求验证。
invalidate := false
busy := false
defer func() {
s.mu.Lock()
defer s.mu.Unlock()
if s.emailPending[key] == pending {
if busy {
pending.Attempts--
}
if invalidate || pending.Attempts >= maxEmailContinuationAttempts {
delete(s.emailPending, key)
} else {
pending.Busy = false
}
}
}()
user, err := s.loadRecoverableUser(account)
if err != nil || user.ID != pending.UserID || user.EffectiveRole() != access.RoleAdmin || user.SessionVersion != pending.SessionVersion || user.SecurityVersion != pending.SecurityVersion {
invalidate = true
return "", ErrRecoveryDenied
}
binding, err := s.external.FindVerifiedByEmail(email)
if err != nil || binding.UserID != pending.UserID || binding.ID != pending.CredentialID || binding.CredentialVersion != pending.CredentialVersion {
invalidate = true
return "", ErrRecoveryDenied
}
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
if errors.Is(err, password.ErrBusy) {
busy = true
return "", err
}
return "", ErrRecoveryDenied
}
invalidate = true
ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "email_admin", binding.ID, binding.CredentialVersion, browserContext)
if err != nil {
return "", err
}
s.mu.Lock()
active := s.emailPending[key] == pending && s.now().Before(pending.ExpiresAt)
delete(s.emailPending, key)
if !active {
delete(s.tickets, ticketKey(ticketID))
}
s.mu.Unlock()
if !active {
return "", ErrRecoveryExpired
}
return ticketID, nil
}