131 lines
4.6 KiB
Go
131 lines
4.6 KiB
Go
package recovery
|
||
|
||
import (
|
||
"errors"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Smail/models"
|
||
webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn"
|
||
"git.pcux.cn/xuans/Smail/utils/password"
|
||
)
|
||
|
||
func TestAuditRecoveryDependenciesAreRaceSafeAndReentrant(t *testing.T) {
|
||
service, _, _, user := newRecoveryFixture(t)
|
||
var calls atomic.Int32
|
||
callback := func(userID int, account string) {
|
||
service.InvalidateUser(userID)
|
||
service.SetSecurityStateInvalidator(nil)
|
||
calls.Add(1)
|
||
}
|
||
verifier := &webauthnservice.Service{}
|
||
var workers sync.WaitGroup
|
||
workers.Add(2)
|
||
go func() {
|
||
defer workers.Done()
|
||
for index := 0; index < 1000; index++ {
|
||
service.SetSecurityStateInvalidator(callback)
|
||
service.SetWebAuthnVerifier(verifier)
|
||
service.SetWebAuthnVerifier(nil)
|
||
}
|
||
}()
|
||
go func() {
|
||
defer workers.Done()
|
||
for index := 0; index < 1000; index++ {
|
||
_ = service.currentWebAuthnVerifier()
|
||
service.invalidateSecurityState(user.ID, user.Account)
|
||
}
|
||
}()
|
||
workers.Wait()
|
||
service.SetSecurityStateInvalidator(callback)
|
||
ticket, err := service.issueTicketFor(user.ID, user.SessionVersion, "", 0, 0, "audit-browser")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
before := calls.Load()
|
||
if err := service.CompleteWithContext(ticket, "AuditPass123", "audit-browser"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if calls.Load() != before+1 {
|
||
t.Fatal("真实密码恢复提交必须执行一次可重入的失效回调")
|
||
}
|
||
}
|
||
|
||
func TestAuditRecoveryRejectsDisabledOrChangedSession(t *testing.T) {
|
||
for _, field := range []string{"disabled", "session_version"} {
|
||
t.Run(field, func(t *testing.T) {
|
||
service, _, engine, user := newRecoveryFixture(t)
|
||
ticket, err := service.issueTicketFor(user.ID, user.SessionVersion, "", 0, 0, "audit-browser")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
update := models.User{Disabled: 1, SessionVersion: user.SessionVersion + 1}
|
||
if _, err := engine.ID(user.ID).Cols(field).Update(&update); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.CompleteWithContext(ticket, "AuditPass123", "audit-browser"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("旧票据应拒绝:%v", err)
|
||
}
|
||
var stored models.User
|
||
if found, err := engine.ID(user.ID).Get(&stored); err != nil || !found || stored.Password != user.Password {
|
||
t.Fatalf("拒绝后密码发生变化:found=%v err=%v", found, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 审查专用:从已验证后签发票据的位置开始,仅验证撤销后的授权有效性。
|
||
// 本测试不模拟或绕过真实 WebAuthn 签名验证,也不调用真实服务。
|
||
func TestAuditRevokedWebAuthnMustInvalidateRecoveryTicket(t *testing.T) {
|
||
service, _, engine, user := newRecoveryFixture(t)
|
||
credential := models.WebAuthnCredential{
|
||
UserID: user.ID, CredentialID: "audit-credential",
|
||
CredentialData: "unused", DisplayName: "audit", Discoverable: true, CreatedAt: time.Now(),
|
||
}
|
||
if _, err := engine.Insert(&credential); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
verifier, err := webauthnservice.NewService(engine, webauthnservice.Config{
|
||
RPID: "localhost", RPDisplayName: "audit", RPOrigins: []string{"https://localhost"}, VaultRoot: t.TempDir(),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ticketID, err := service.issueTicketFor(user.ID, user.SessionVersion, "webauthn", credential.ID, 0, "audit-browser")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := verifier.RevokeCredential(user.ID, credential.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var revoked models.WebAuthnCredential
|
||
if found, err := engine.ID(credential.ID).Get(&revoked); err != nil || !found || revoked.RevokedAt == nil {
|
||
t.Fatalf("撤销凭据的前置条件未成立: found=%v err=%v", found, err)
|
||
}
|
||
const newPassword = "AuditPass123"
|
||
if err := password.ValidateUserPassword(newPassword); err != nil {
|
||
t.Fatalf("测试密码不符合当前策略: %v", err)
|
||
}
|
||
err = service.CompleteWithContext(ticketID, newPassword, "audit-browser")
|
||
var updated models.User
|
||
if found, lookupErr := engine.ID(user.ID).Get(&updated); lookupErr != nil || !found {
|
||
t.Fatal("无法检查临时账户")
|
||
}
|
||
t.Logf("revoked=true completion_error=%v password_changed=%v", err, updated.Password != user.Password)
|
||
if errors.Is(err, ErrRecoveryDenied) || errors.Is(err, ErrRecoveryExpired) {
|
||
if updated.Password != user.Password {
|
||
t.Fatal("恢复被拒绝后不应改变密码")
|
||
}
|
||
return
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("出现非授权类错误,本次结果不能证明撤销失效: %v", err)
|
||
}
|
||
if !password.Verify(updated.Password, newPassword) {
|
||
t.Fatal("完成接口未返回错误,但新密码未生效,需要单独诊断")
|
||
}
|
||
t.Fatal("用于签发恢复票据的安全密钥已经撤销,但旧票据仍然允许修改密码")
|
||
}
|