254 lines
8.1 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
}
|