147 lines
4.8 KiB
Go
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
|
|
}
|