完善匿名恢复链接安全校验
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 12:14:22 +08:00
parent 5604b81ff8
commit a6b095ea77
3 changed files with 52 additions and 0 deletions

View File

@ -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

View File

@ -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"

View File

@ -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)