Smail/backend/services/recovery/public_flow.go
smail e408287dd4
Some checks are pending
Security Checks / security (push) Waiting to run
完善登录页分角色密码重置流程并保留安全中心入口
2026-09-18 18:20:53 +08:00

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
}