diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 2757f6f5..1c6d1703 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -703,6 +703,34 @@ func CompletePublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"reset": true}) } +// PublicLinkPasswordRecoveryRequirements 只返回恢复页渲染所需的最小信息。 +// 一次性令牌不写入日志,也不返回账户、邮箱或角色名称。 +func PublicLinkPasswordRecoveryRequirements(w http.ResponseWriter, req *http.Request) { + if !PublicMailRecoveryEnabled() || req == nil { + writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"available": false}) + return + } + var input struct { + Token string `json:"token"` + } + decoder := json.NewDecoder(req.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"available": false}) + return + } + runtime := currentSecurityRuntime() + requiresRecoveryCode := false + if runtime.recovery != nil { + var err error + requiresRecoveryCode, err = runtime.recovery.PublicLinkRequiresRecoveryCode(input.Token) + if err != nil { + requiresRecoveryCode = false + } + } + writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"requires_recovery_code": requiresRecoveryCode}) +} + func writePublicRecoveryJSON(w http.ResponseWriter, status int, value any) { if w == nil { return diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index 23241410..9296ee9d 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -8,6 +8,7 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/dto/parsemail" + "git.pcux.cn/xuans/Smail/services/recovery" "git.pcux.cn/xuans/Smail/services/verification" appcontext "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/send" diff --git a/backend/services/recovery/public_link.go b/backend/services/recovery/public_link.go index 2ac8174f..d55165cc 100644 --- a/backend/services/recovery/public_link.go +++ b/backend/services/recovery/public_link.go @@ -69,6 +69,12 @@ func (s *Service) BeginPublicLink(identifier string) error { } for ticketKey, item := range s.publicTickets { if item.UserID == user.ID { + // 正在提交的旧链接优先完成,期间不签发会与其竞争的新链接。 + // 调用方统一隐藏该结果,因此不会泄露账户或链接状态。 + if item.Busy { + s.mu.Unlock() + return ErrRecoveryDenied + } delete(s.publicTickets, ticketKey) } } @@ -89,6 +95,23 @@ func (s *Service) BeginPublicLink(identifier string) error { return nil } +// PublicLinkRequiresRecoveryCode 返回当前一次性链接是否属于管理员。 +// 此方法只为恢复页决定是否展示恢复码字段;令牌必须由收件人持有,且不会返回账号或角色详情。 +func (s *Service) PublicLinkRequiresRecoveryCode(token string) (bool, error) { + if s == nil || strings.TrimSpace(token) == "" { + return false, ErrInvalidRequest + } + key := ticketKey(token) + s.mu.Lock() + defer s.mu.Unlock() + s.cleanupLocked(s.now()) + item, ok := s.publicTickets[key] + if !ok || item.Busy { + return false, ErrRecoveryExpired + } + return item.Role == access.RoleAdmin, nil +} + // CompletePublicLink 在同一数据库事务中完成管理员恢复码核销、密码更新和旧凭据撤销。 func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) error { defer clearString(&newPassword)