228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
package recovery
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/services/access"
|
|
"git.pcux.cn/xuans/Smail/services/auth"
|
|
"git.pcux.cn/xuans/Smail/services/verification"
|
|
"git.pcux.cn/xuans/Smail/utils/password"
|
|
)
|
|
|
|
type PublicFlow struct {
|
|
Token string `json:"token,omitempty"`
|
|
Role access.Role `json:"role"`
|
|
Email string `json:"email"`
|
|
Methods []string `json:"methods"`
|
|
}
|
|
|
|
// IdentifyPublicFlow 只公开脱敏邮箱,验证方式由数据库角色和已配置凭据决定。
|
|
func (s *Service) IdentifyPublicFlow(identifier string) (*PublicFlow, error) {
|
|
if s == nil || s.engine == nil || s.external == nil || s.credentials == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
account, err := auth.NormalizeIdentifier(identifier)
|
|
if err != nil || account == "" {
|
|
return nil, ErrRecoveryDenied
|
|
}
|
|
var user models.User
|
|
found, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user)
|
|
if err != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if !found {
|
|
return nil, ErrRecoveryDenied
|
|
}
|
|
result := &PublicFlow{Role: user.EffectiveRole(), Methods: []string{}}
|
|
if result.Role == access.RoleSuperAdmin {
|
|
return result, nil
|
|
}
|
|
item := &publicTicket{Flow: true, UserID: user.ID, Account: user.Account, Role: user.EffectiveRole(), SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion}
|
|
bindings, err := s.external.List(user.ID)
|
|
if err != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
var binding *models.ExternalEmail
|
|
for i := range bindings {
|
|
candidate := &bindings[i]
|
|
if candidate.Status != models.ExternalEmailStatusVerified {
|
|
continue
|
|
}
|
|
if candidate.EmailType == models.ExternalEmailTypePrimary {
|
|
binding = candidate
|
|
break
|
|
}
|
|
if candidate.EmailType == models.ExternalEmailTypeBackup && binding == nil {
|
|
binding = candidate
|
|
}
|
|
}
|
|
if binding != nil {
|
|
email, err := s.external.Decrypt(*binding)
|
|
if err != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
parts := strings.SplitN(email, "@", 2)
|
|
if len(parts) != 2 || parts[0] == "" {
|
|
return nil, ErrUnavailable
|
|
}
|
|
result.Email = string([]rune(parts[0])[0]) + "***@" + parts[1]
|
|
item.CredentialID, item.CredentialVersion = binding.ID, binding.CredentialVersion
|
|
if s.verification != nil {
|
|
result.Methods = append(result.Methods, "email")
|
|
}
|
|
}
|
|
if result.Role == access.RoleUser {
|
|
configured, err := s.credentials.HasValidRecoveryCode(user.ID)
|
|
if err != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if configured {
|
|
result.Methods = append(result.Methods, "security_code")
|
|
}
|
|
}
|
|
if len(result.Methods) == 0 {
|
|
return result, nil
|
|
}
|
|
token, err := newTicketID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cleanupLocked(s.now())
|
|
if len(s.publicTickets) >= maxRecoveryTickets {
|
|
return nil, ErrUnavailable
|
|
}
|
|
item.ExpiresAt = s.now().Add(decoyChallengeTTL)
|
|
s.publicTickets[ticketKey(token)] = item
|
|
result.Token = token
|
|
return result, nil
|
|
}
|
|
|
|
// lockPublicFlow 占用当前步骤,防止验证码发送、核销和密码提交并发复用。
|
|
func (s *Service) lockPublicFlow(token string) (*publicTicket, error) {
|
|
if s == nil || strings.TrimSpace(token) == "" {
|
|
return nil, ErrRecoveryDenied
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cleanupLocked(s.now())
|
|
item := s.publicTickets[ticketKey(token)]
|
|
if item == nil || !item.Flow || item.Verified || item.Busy {
|
|
return nil, ErrRecoveryExpired
|
|
}
|
|
item.Busy = true
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) releasePublicFlow(token string, item *publicTicket) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.publicTickets[ticketKey(token)] == item {
|
|
item.Busy = false
|
|
}
|
|
}
|
|
|
|
func (s *Service) publicFlowIdentity(item *publicTicket) (string, error) {
|
|
var user models.User
|
|
found, err := s.engine.ID(item.UserID).Get(&user)
|
|
if err != nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
if !found || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || user.EffectiveRole() != item.Role || user.SecurityVersion != item.SecurityVersion || user.SessionVersion != item.SessionVersion {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
if item.CredentialID == 0 {
|
|
return "", nil
|
|
}
|
|
binding, err := s.external.FindVerifiedByID(user.ID, item.CredentialID)
|
|
if err != nil || binding.CredentialVersion != item.CredentialVersion {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
return s.external.Decrypt(*binding)
|
|
}
|
|
|
|
func (s *Service) SendPublicFlowCode(token, remoteIP string) error {
|
|
item, err := s.lockPublicFlow(token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.releasePublicFlow(token, item)
|
|
email, err := s.publicFlowIdentity(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if email == "" || s.verification == nil {
|
|
return ErrRecoveryDenied
|
|
}
|
|
challenge, err := s.verification.Issue(verification.IssueRequest{
|
|
Purpose: verification.PurposePasswordRecover, UserID: item.UserID, Account: item.Account,
|
|
Email: email, ResourceID: item.CredentialID, CredentialVersion: item.CredentialVersion,
|
|
BrowserTransaction: token,
|
|
}, remoteIP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
item.ChallengeID = challenge.ID
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) VerifyPublicFlow(token, method, code, remoteIP string) (string, error) {
|
|
item, err := s.lockPublicFlow(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer s.releasePublicFlow(token, item)
|
|
email, err := s.publicFlowIdentity(item)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// 提前生成下一步令牌,避免随机源失败导致已核销安全码无法继续。
|
|
next, err := newTicketID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch method {
|
|
case "email":
|
|
if email == "" || item.ChallengeID == "" || s.verification == nil {
|
|
err = ErrRecoveryDenied
|
|
} else {
|
|
err = s.verification.VerifyBoundWithContext(item.ChallengeID, item.Account, email, strings.TrimSpace(code), verification.PurposePasswordRecover, item.CredentialID, remoteIP, token)
|
|
}
|
|
case "security_code":
|
|
if item.Role != access.RoleUser {
|
|
err = ErrRecoveryDenied
|
|
} else {
|
|
err = s.credentials.ConsumeRecoveryCode(item.UserID, code)
|
|
}
|
|
default:
|
|
err = ErrRecoveryDenied
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, password.ErrBusy) {
|
|
return "", err
|
|
}
|
|
s.mu.Lock()
|
|
item.Attempts++
|
|
if item.Attempts >= maxPublicRecoveryCodeAttempts {
|
|
delete(s.publicTickets, ticketKey(token))
|
|
}
|
|
s.mu.Unlock()
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.publicTickets[ticketKey(token)] != item || !s.now().Before(item.ExpiresAt) {
|
|
return "", ErrRecoveryExpired
|
|
}
|
|
delete(s.publicTickets, ticketKey(token))
|
|
item.Verified, item.Method, item.Busy = true, method, false
|
|
item.ExpiresAt = s.now().Add(authorizationTTL)
|
|
s.publicTickets[ticketKey(next)] = item
|
|
return next, nil
|
|
}
|