From c47155d6009f9aaf86059a8cff45ac1c3f4214ba Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 3 Oct 2026 20:12:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E4=BB=85=E9=85=8D=E7=BD=AE=E5=AE=89=E5=85=A8=E7=A0=81=E6=97=B6?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E7=8B=AC=E7=AB=8B=E6=89=BE=E5=9B=9E=E5=AF=86?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/recovery/public_flow.go | 4 +- backend/services/recovery/public_flow_test.go | 56 +++++++++++++++++-- backend/services/recovery/public_link.go | 2 +- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/backend/services/recovery/public_flow.go b/backend/services/recovery/public_flow.go index c49852a..7bd04db 100644 --- a/backend/services/recovery/public_flow.go +++ b/backend/services/recovery/public_flow.go @@ -73,7 +73,7 @@ func (s *Service) IdentifyPublicFlow(identifier string) (*PublicFlow, error) { result.Methods = append(result.Methods, "email") } } - if result.Role == access.RoleUser { + if result.Role == access.RoleUser || result.Role == access.RoleAdmin { configured, err := s.credentials.HasValidRecoveryCode(user.ID) if err != nil { return nil, ErrUnavailable @@ -194,7 +194,7 @@ func (s *Service) VerifyPublicFlow(token, method, code, remoteIP string) (string 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 { + if item.Role != access.RoleUser && item.Role != access.RoleAdmin { err = ErrRecoveryDenied } else { err = s.credentials.ConsumeRecoveryCode(item.UserID, code) diff --git a/backend/services/recovery/public_flow_test.go b/backend/services/recovery/public_flow_test.go index dfc9a71..bf40497 100644 --- a/backend/services/recovery/public_flow_test.go +++ b/backend/services/recovery/public_flow_test.go @@ -130,13 +130,20 @@ func TestPublicFlowEmailResetAndRoleBoundary(t *testing.T) { } func TestPublicFlowSecurityCodeRoleAndFailureLimits(t *testing.T) { - for _, role := range []access.Role{access.RoleAdmin, access.RoleUser} { + for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} { t.Run(string(role), func(t *testing.T) { s, _, engine, user := newRecoveryFixture(t) user.Role = string(role) if _, err := engine.ID(user.ID).Cols("role").Update(user); err != nil { t.Fatal(err) } + binding, err := s.external.FindVerifiedPrimaryByUser(user.ID) + if err != nil { + t.Fatal(err) + } + if err := s.external.Disable(user.ID, binding.ID); err != nil { + t.Fatal(err) + } codes, err := s.credentials.GenerateRecoveryCodes(user.ID, 5) if err != nil { t.Fatal(err) @@ -145,19 +152,55 @@ func TestPublicFlowSecurityCodeRoleAndFailureLimits(t *testing.T) { if err != nil { t.Fatal(err) } - ticket, err := s.VerifyPublicFlow(flow.Token, "security_code", codes[0], "192.0.2.2") - if role == access.RoleAdmin { - if err == nil || strings.Contains(strings.Join(flow.Methods, ","), "security_code") { - t.Fatal("admin used security code instead of email") + if role == access.RoleSuperAdmin { + if flow.Token != "" || len(flow.Methods) != 0 || flow.Email != "" { + t.Fatal("超级管理员不应获得公共安全码恢复入口") + } + if _, err := s.VerifyPublicFlow(flow.Token, "security_code", codes[0], "192.0.2.2"); err == nil { + t.Fatal("超级管理员安全码被公共恢复流程接受") } return } + if flow.Token == "" || flow.Email != "" || len(flow.Methods) != 1 || flow.Methods[0] != "security_code" { + t.Fatal("仅配置安全码的账号未获得独立恢复方式") + } + if err := s.CompletePublicFlow(flow.Token, "NewPass123"); err == nil { + t.Fatal("未经安全码验证就完成了密码重置") + } + ticket, err := s.VerifyPublicFlow(flow.Token, "security_code", codes[0], "192.0.2.2") if err != nil { t.Fatal(err) } + replay, err := s.IdentifyPublicFlow(user.Account) + if err != nil { + t.Fatal(err) + } + if _, err := s.VerifyPublicFlow(replay.Token, "security_code", codes[0], "192.0.2.2"); err == nil { + t.Fatal("已核销的安全码被重复使用") + } if err := s.CompletePublicFlow(ticket, "NewPass123"); err != nil { t.Fatal(err) } + var updated models.User + if found, err := engine.ID(user.ID).Get(&updated); err != nil || !found { + t.Fatal("无法读取重置后的账号", err) + } + if ok, err := password.Check(updated.Password, "NewPass123"); err != nil || !ok { + t.Fatal("安全码恢复未更新密码", err) + } + if updated.SessionVersion != user.SessionVersion+1 || updated.SecurityVersion != user.SecurityVersion+1 { + t.Fatal("安全码恢复未更新密码或撤销旧安全状态") + } + if configured, err := s.credentials.HasValidRecoveryCode(user.ID); err != nil || configured { + t.Fatal("密码重置后仍保留旧安全码", err) + } + if err := s.CompletePublicFlow(ticket, "OtherPass123"); err == nil { + t.Fatal("密码重置令牌被重复使用") + } + codes, err = s.credentials.GenerateRecoveryCodes(user.ID, 5) + if err != nil { + t.Fatal(err) + } flow, err = s.IdentifyPublicFlow(user.Account) if err != nil { t.Fatal(err) @@ -170,6 +213,9 @@ func TestPublicFlowSecurityCodeRoleAndFailureLimits(t *testing.T) { if err := s.SendPublicFlowCode(flow.Token, "192.0.2.2"); err == nil { t.Fatal("exhausted flow usable") } + if _, err := s.VerifyPublicFlow(flow.Token, "security_code", codes[0], "192.0.2.2"); err == nil { + t.Fatal("达到失败次数上限后仍可验证有效安全码") + } }) } } diff --git a/backend/services/recovery/public_link.go b/backend/services/recovery/public_link.go index 484b64a..27c7dc5 100644 --- a/backend/services/recovery/public_link.go +++ b/backend/services/recovery/public_link.go @@ -151,7 +151,7 @@ func (s *Service) completePublicLink(token, newPassword, recoveryCode string, fl s.mu.Unlock() return ErrRecoveryDenied } - if item.Flow != flow || (flow && (!item.Verified || (item.Role == access.RoleAdmin && item.Method != "email"))) { + if item.Flow != flow || (flow && (!item.Verified || (item.Method != "email" && item.Method != "security_code"))) { s.mu.Unlock() return ErrRecoveryDenied }