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

254 lines
8.1 KiB
Go

package recovery
import (
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/verification"
"git.pcux.cn/xuans/Smail/utils/password"
)
func TestPublicFlowEmailResetAndRoleBoundary(t *testing.T) {
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
t.Run(string(role), func(t *testing.T) {
s, verify, engine, user := newRecoveryFixture(t)
user.Role = string(role)
if _, err := engine.ID(user.ID).Cols("role").Update(user); err != nil {
t.Fatal(err)
}
var code string
verify.Send = func(_ verification.Challenge, value string) error { code = value; return nil }
flow, err := s.IdentifyPublicFlow(user.Account)
if err != nil {
t.Fatal(err)
}
if role == access.RoleSuperAdmin {
if flow.Token != "" || len(flow.Methods) != 0 || flow.Email != "" {
t.Fatal("super administrator received public recovery access")
}
return
}
if flow.Email != "a***@example.com" || flow.Role != role {
t.Fatalf("unexpected identity: %#v", flow)
}
if err := s.CompletePublicFlow(flow.Token, "NewPass123"); err == nil {
t.Fatal("reset before verification")
}
if err := s.CompletePublicLink(flow.Token, "NewPass123", ""); err == nil {
t.Fatal("flow accepted by legacy completion")
}
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.1"); err != nil {
t.Fatal(err)
}
if len(code) != 8 {
t.Fatal("email code not delivered")
}
if _, err := s.VerifyPublicFlow(flow.Token, "email", "bad-code", "192.0.2.1"); err == nil {
t.Fatal("invalid code accepted")
}
ticket, err := s.VerifyPublicFlow(flow.Token, "email", code, "192.0.2.1")
if err != nil {
t.Fatal(err)
}
if ticket == flow.Token {
t.Fatal("verification token not rotated")
}
if _, err := s.VerifyPublicFlow(flow.Token, "email", code, "192.0.2.1"); err == nil {
t.Fatal("verification replay accepted")
}
if err := s.CompletePublicFlow(ticket, "short"); err == nil {
t.Fatal("invalid password accepted")
}
if err := s.CompletePublicFlow(ticket, "NewPass123"); err != nil {
t.Fatal(err)
}
if err := s.CompletePublicFlow(ticket, "OtherPass12"); err == nil {
t.Fatal("ticket replay accepted")
}
var updated models.User
if _, err := engine.ID(user.ID).Get(&updated); err != nil {
t.Fatal(err)
}
if ok, err := password.Check(updated.Password, "NewPass123"); err != nil || !ok {
t.Fatal("new password not applied", err)
}
if updated.SessionVersion != user.SessionVersion+1 {
t.Fatal("old sessions still valid")
}
})
}
}
func TestPublicFlowSecurityCodeRoleAndFailureLimits(t *testing.T) {
for _, role := range []access.Role{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)
}
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)
}
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")
}
return
}
if err != nil {
t.Fatal(err)
}
if err := s.CompletePublicFlow(ticket, "NewPass123"); err != nil {
t.Fatal(err)
}
flow, err = s.IdentifyPublicFlow(user.Account)
if err != nil {
t.Fatal(err)
}
for i := 0; i < maxPublicRecoveryCodeAttempts; i++ {
if _, err := s.VerifyPublicFlow(flow.Token, "security_code", "invalid", "192.0.2.2"); err == nil {
t.Fatal("bad code accepted")
}
}
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.2"); err == nil {
t.Fatal("exhausted flow usable")
}
})
}
}
func TestPublicFlowRejectsExpiredAndChangedAccount(t *testing.T) {
for _, scenario := range []string{"expired", "disabled", "promoted", "security_version", "email_disabled"} {
t.Run(scenario, func(t *testing.T) {
s, verify, engine, user := newRecoveryFixture(t)
verify.Send = func(verification.Challenge, string) error { t.Fatal("sent code after invalidation"); return nil }
flow, err := s.IdentifyPublicFlow(user.Account)
if err != nil {
t.Fatal(err)
}
switch scenario {
case "expired":
now := s.now()
s.now = func() time.Time { return now.Add(20 * time.Minute) }
case "disabled":
user.Disabled = 1
_, err = engine.ID(user.ID).Cols("disabled").Update(user)
case "promoted":
user.Role = string(access.RoleSuperAdmin)
_, err = engine.ID(user.ID).Cols("role").Update(user)
case "security_version":
user.SecurityVersion++
_, err = engine.ID(user.ID).Cols("security_version").Update(user)
case "email_disabled":
binding, lookup := s.external.FindVerifiedPrimaryByUser(user.ID)
if lookup != nil {
t.Fatal(lookup)
}
err = s.external.Disable(user.ID, binding.ID)
}
if err != nil {
t.Fatal(err)
}
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.3"); err == nil {
t.Fatal("invalidated flow accepted")
}
})
}
}
func TestPublicFlowRejectsCrossFlowCodeAndConcurrentUse(t *testing.T) {
s, verify, _, user := newRecoveryFixture(t)
first, err := s.IdentifyPublicFlow(user.Account)
if err != nil {
t.Fatal(err)
}
second, err := s.IdentifyPublicFlow(user.Account)
if err != nil {
t.Fatal(err)
}
started, release := make(chan struct{}), make(chan struct{})
var code string
verify.Send = func(_ verification.Challenge, value string) error {
code = value
close(started)
<-release
return nil
}
done := make(chan error, 1)
go func() { done <- s.SendPublicFlowCode(first.Token, "192.0.2.4") }()
<-started
_, concurrentErr := s.VerifyPublicFlow(first.Token, "email", code, "192.0.2.4")
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
if concurrentErr == nil {
t.Fatal("verification accepted while delivery was in progress")
}
// 即使另一流程取得挑战编号,也不能跨流程使用邮件验证码。
s.publicTickets[ticketKey(second.Token)].ChallengeID = s.publicTickets[ticketKey(first.Token)].ChallengeID
if _, err := s.VerifyPublicFlow(second.Token, "email", code, "192.0.2.4"); err == nil {
t.Fatal("cross-flow email code accepted")
}
if _, err := s.VerifyPublicFlow(first.Token, "email", code, "192.0.2.4"); err != nil {
t.Fatal(err)
}
}
func TestPublicFlowRechecksAccountAfterVerification(t *testing.T) {
for _, scenario := range []string{"expired", "disabled", "promoted", "security_version", "email_disabled"} {
t.Run(scenario, func(t *testing.T) {
s, verify, engine, user := newRecoveryFixture(t)
var code string
verify.Send = func(_ verification.Challenge, value string) error { code = value; return nil }
flow, err := s.IdentifyPublicFlow(user.Account)
if err != nil {
t.Fatal(err)
}
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.5"); err != nil {
t.Fatal(err)
}
token, err := s.VerifyPublicFlow(flow.Token, "email", code, "192.0.2.5")
if err != nil {
t.Fatal(err)
}
switch scenario {
case "expired":
now := s.now()
s.now = func() time.Time { return now.Add(20 * time.Minute) }
case "disabled":
user.Disabled = 1
_, err = engine.ID(user.ID).Cols("disabled").Update(user)
case "promoted":
user.Role = string(access.RoleSuperAdmin)
_, err = engine.ID(user.ID).Cols("role").Update(user)
case "security_version":
user.SecurityVersion++
_, err = engine.ID(user.ID).Cols("security_version").Update(user)
case "email_disabled":
binding, lookup := s.external.FindVerifiedPrimaryByUser(user.ID)
if lookup != nil {
t.Fatal(lookup)
}
err = s.external.Disable(user.ID, binding.ID)
}
if err != nil {
t.Fatal(err)
}
if err := s.CompletePublicFlow(token, "NewPass123"); err == nil {
t.Fatal("password changed after account or verification invalidation")
}
})
}
}