Smail/backend/services/recovery/webauthn_recovery_test.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

126 lines
4.7 KiB
Go

package recovery
import (
"errors"
"net/http"
"testing"
"time"
"git.pcux.cn/xuans/Smail/models"
"github.com/go-webauthn/webauthn/protocol"
)
type fakeWebAuthnVerifier struct {
beginCalls int
finishCalls int
resultUser *models.User
sessionID string
finishErr error
}
func (f *fakeWebAuthnVerifier) BeginDiscoverableLogin() (*protocol.CredentialAssertion, string, error) {
f.beginCalls++
if f.sessionID == "" {
f.sessionID = "webauthn-recovery-session"
}
return &protocol.CredentialAssertion{}, f.sessionID, nil
}
func (f *fakeWebAuthnVerifier) FinishDiscoverableLogin(sessionID string, request *http.Request) (*models.User, *models.WebAuthnCredential, error) {
f.finishCalls++
if f.finishErr != nil {
return nil, nil, f.finishErr
}
if request == nil || sessionID == "" || sessionID != f.sessionID || f.resultUser == nil {
return nil, nil, errors.New("invalid assertion")
}
return f.resultUser, &models.WebAuthnCredential{UserID: f.resultUser.ID}, nil
}
func TestWebAuthnRecoveryIssuesOneTimeTicketAndRevokesCredentials(t *testing.T) {
service, _, engine, user := newRecoveryFixture(t)
verifier := &fakeWebAuthnVerifier{resultUser: user}
service.SetWebAuthnVerifier(verifier)
if err := service.credentials.SetSecurityQuestion(user.ID, "First pet?", "BlueBird123"); err != nil {
t.Fatal(err)
}
codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5)
if err != nil || len(codes) != 5 {
t.Fatalf("recovery codes: %v, %v", codes, err)
}
if _, err := engine.Insert(&models.WebAuthnCredential{UserID: user.ID, CredentialID: "credential-id", CredentialData: "enc:v1:opaque", DisplayName: "Key", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
options, sessionID, err := service.BeginWebAuthn(user.Account)
if err != nil || options == nil || sessionID == "" || verifier.beginCalls != 1 {
t.Fatalf("begin webauthn recovery: options=%v session=%q calls=%d err=%v", options, sessionID, verifier.beginCalls, err)
}
ticketID, err := service.VerifyWebAuthn(user.Account, sessionID, &http.Request{})
if err != nil || ticketID == "" || verifier.finishCalls != 1 {
t.Fatalf("verify webauthn recovery: ticket=%q calls=%d err=%v", ticketID, verifier.finishCalls, err)
}
if err := service.Complete(ticketID, "WebAuthnP12"); err != nil {
t.Fatal(err)
}
if err := service.Complete(ticketID, "ReplayP123"); !errors.Is(err, ErrRecoveryExpired) {
t.Fatalf("replayed ticket error = %v", err)
}
if count, err := engine.Where("user_id = ?", user.ID).Count(&models.SecurityQuestion{}); err != nil || count != 0 {
t.Fatalf("security questions count=%d err=%v", count, err)
}
if count, err := engine.Where("user_id = ? AND used_at IS NULL", user.ID).Count(&models.RecoveryCode{}); err != nil || count != 0 {
t.Fatalf("active recovery codes count=%d err=%v", count, err)
}
if count, err := engine.Where("user_id = ? AND revoked_at IS NULL", user.ID).Count(&models.WebAuthnCredential{}); err != nil || count != 0 {
t.Fatalf("active webauthn credentials count=%d err=%v", count, err)
}
}
func TestWebAuthnRecoveryTicketExpiresAndSuperAdminIsDenied(t *testing.T) {
service, _, engine, user := newRecoveryFixture(t)
verifier := &fakeWebAuthnVerifier{resultUser: user}
service.SetWebAuthnVerifier(verifier)
_, sessionID, err := service.BeginWebAuthn(user.Account)
if err != nil {
t.Fatal(err)
}
ticketID, err := service.VerifyWebAuthn(user.Account, sessionID, &http.Request{})
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return time.Now().Add(authorizationTTL + time.Second) }
if err := service.Complete(ticketID, "ExpireP123"); !errors.Is(err, ErrRecoveryExpired) {
t.Fatalf("expired ticket error = %v", err)
}
super := &models.User{Account: "root-webauthn", Name: "Root", Password: "unused", Role: "super_admin", SessionVersion: 1}
if _, err := engine.Insert(super); err != nil {
t.Fatal(err)
}
verifier.resultUser = super
_, superSessionID, err := service.BeginWebAuthn(super.Account)
if err != nil {
t.Fatalf("super administrator begin error = %v", err)
}
if _, err := service.VerifyWebAuthn(super.Account, superSessionID, &http.Request{}); !errors.Is(err, ErrRecoveryDenied) {
t.Fatalf("super administrator verification error = %v", err)
}
}
func TestWebAuthnRecoveryRejectsFailedAssertion(t *testing.T) {
service, _, _, user := newRecoveryFixture(t)
verifier := &fakeWebAuthnVerifier{resultUser: user, finishErr: errors.New("signature rejected")}
service.SetWebAuthnVerifier(verifier)
_, sessionID, err := service.BeginWebAuthn(user.Account)
if err != nil {
t.Fatal(err)
}
if _, err := service.VerifyWebAuthn(user.Account, sessionID, &http.Request{}); !errors.Is(err, ErrRecoveryDenied) {
t.Fatalf("failed assertion error = %v", err)
}
}