Some checks are pending
Security Checks / security (push) Waiting to run
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
416 lines
15 KiB
Go
416 lines
15 KiB
Go
package recovery
|
||
|
||
import (
|
||
"errors"
|
||
"path/filepath"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git.pcux.cn/xuans/Smail/models"
|
||
"git.pcux.cn/xuans/Smail/services/access"
|
||
"git.pcux.cn/xuans/Smail/services/auth"
|
||
"git.pcux.cn/xuans/Smail/services/credentials"
|
||
"git.pcux.cn/xuans/Smail/services/externalemail"
|
||
"git.pcux.cn/xuans/Smail/services/verification"
|
||
_ "modernc.org/sqlite"
|
||
"xorm.io/xorm"
|
||
)
|
||
|
||
func newRecoveryFixture(t *testing.T) (*Service, *verification.Service, *xorm.Engine, *models.User) {
|
||
t.Helper()
|
||
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "recovery.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = engine.Close() })
|
||
for _, table := range []any{&models.User{}, &models.ExternalEmail{}, &models.Sessions{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.WebAuthnCredential{}, &models.ProtocolCredential{}} {
|
||
if err := engine.Sync2(table); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
password, err := auth.HashPassword("OldPass123")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
user := &models.User{Account: "alice", Name: "Alice", Password: password, Role: string(access.RoleUser), SessionVersion: 1}
|
||
if _, err := engine.Insert(user); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
vault := externalemail.NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
|
||
external, err := externalemail.NewRepository(engine, vault)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
binding, err := external.Bind(externalemail.BindRequest{UserID: user.ID, Email: "alice@example.com", Type: models.ExternalEmailTypePrimary})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := external.Verify(user.ID, binding.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
store, err := verification.NewStore(32)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service, err := verification.NewService(store, func(_ verification.Challenge, _ string) error { return nil }, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
credential, err := credentials.NewService(engine)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
recoveryService, err := NewService(engine, service, external, credential)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return recoveryService, service, engine, user
|
||
}
|
||
|
||
func TestRecoveryRejectsCrossUserVerificationAndConsumesTicketOnce(t *testing.T) {
|
||
recoveryService, verifyService, engine, user := newRecoveryFixture(t)
|
||
var code string
|
||
verifyService.Send = func(_ verification.Challenge, issued string) error {
|
||
code = issued
|
||
return nil
|
||
}
|
||
challengeID, err := recoveryService.Begin(BeginRequest{Identifier: "alice", Email: "alice@example.com"}, "192.0.2.1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := recoveryService.Verify(challengeID, "other", "alice@example.com", code, "", "192.0.2.1"); err == nil {
|
||
t.Fatal("cross-user recovery verification was accepted")
|
||
}
|
||
ticketID, err := recoveryService.Verify(challengeID, user.Account, "alice@example.com", code, "", "192.0.2.1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := recoveryService.Complete(ticketID, "NewPass123"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := recoveryService.Complete(ticketID, "NextPass123"); !errors.Is(err, ErrRecoveryExpired) {
|
||
t.Fatalf("replayed recovery ticket error = %v", err)
|
||
}
|
||
var sessions []models.Sessions
|
||
if err := engine.Find(&sessions); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(sessions) != 0 {
|
||
t.Fatalf("sessions were not revoked: %d", len(sessions))
|
||
}
|
||
}
|
||
|
||
func TestRecoveryTicketConcurrentConsumptionHasOneWinner(t *testing.T) {
|
||
recoveryService, verifyService, _, _ := newRecoveryFixture(t)
|
||
var code string
|
||
verifyService.Send = func(_ verification.Challenge, issued string) error { code = issued; return nil }
|
||
challengeID, err := recoveryService.Begin(BeginRequest{Identifier: "alice", Email: "alice@example.com"}, "192.0.2.2")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ticketID, err := recoveryService.Verify(challengeID, "alice", "alice@example.com", code, "", "192.0.2.2")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var wg sync.WaitGroup
|
||
results := make(chan error, 2)
|
||
for i := 0; i < 2; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
results <- recoveryService.Complete(ticketID, "RacePass123")
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
close(results)
|
||
winners := 0
|
||
for result := range results {
|
||
if result == nil {
|
||
winners++
|
||
continue
|
||
}
|
||
if !errors.Is(result, ErrRecoveryExpired) {
|
||
t.Fatalf("unexpected concurrent completion error: %v", result)
|
||
}
|
||
}
|
||
if winners != 1 {
|
||
t.Fatalf("concurrent recovery winners = %d, want 1", winners)
|
||
}
|
||
}
|
||
|
||
func TestRecoveryDeniesSuperAdministrator(t *testing.T) {
|
||
recoveryService, verifyService, engine, _ := newRecoveryFixture(t)
|
||
deliveries := 0
|
||
verifyService.Send = func(_ verification.Challenge, _ string) error {
|
||
deliveries++
|
||
return nil
|
||
}
|
||
password, err := auth.HashPassword("SuperPass12")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
super := &models.User{Account: "root", Name: "Root", Password: password, Role: string(access.RoleSuperAdmin), SessionVersion: 1}
|
||
if _, err := engine.Insert(super); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
id, err := recoveryService.Begin(BeginRequest{Identifier: "root", Email: "alice@example.com"}, "192.0.2.3")
|
||
if err != nil || len(id) != 32 {
|
||
t.Fatalf("super administrator decoy challenge id=%q err=%v", id, err)
|
||
}
|
||
if deliveries != 0 {
|
||
t.Fatalf("decoy challenge deliveries=%d", deliveries)
|
||
}
|
||
if _, err := recoveryService.Verify(id, "root", "alice@example.com", "12345678", "", "192.0.2.3"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("super administrator decoy verification error = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRecoveryBeginUsesIndistinguishableDecoysWithoutDelivery(t *testing.T) {
|
||
recoveryService, verifyService, _, user := newRecoveryFixture(t)
|
||
deliveries := 0
|
||
verifyService.Send = func(_ verification.Challenge, _ string) error {
|
||
deliveries++
|
||
return nil
|
||
}
|
||
|
||
realID, err := recoveryService.Begin(BeginRequest{Identifier: user.Account, Email: "alice@example.com"}, "192.0.2.10")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
unknownID, err := recoveryService.Begin(BeginRequest{Identifier: "missing", Email: "missing@example.com"}, "192.0.2.11")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mismatchID, err := recoveryService.Begin(BeginRequest{Identifier: user.Account, Email: "missing@example.com"}, "192.0.2.12")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(realID) != 32 || len(unknownID) != len(realID) || len(mismatchID) != len(realID) {
|
||
t.Fatalf("challenge lengths real=%d unknown=%d mismatch=%d", len(realID), len(unknownID), len(mismatchID))
|
||
}
|
||
if deliveries != 1 {
|
||
t.Fatalf("deliveries=%d, want 1", deliveries)
|
||
}
|
||
for _, attempt := range []struct {
|
||
id string
|
||
account string
|
||
email string
|
||
}{
|
||
{id: unknownID, account: "missing", email: "missing@example.com"},
|
||
{id: mismatchID, account: user.Account, email: "missing@example.com"},
|
||
} {
|
||
if _, err := recoveryService.Verify(attempt.id, attempt.account, attempt.email, "12345678", "", "192.0.2.13"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("decoy verification error=%v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAdministratorEmailRecoveryRequiresAndConsumesOfflineCode(t *testing.T) {
|
||
setup := func(t *testing.T) (*Service, *verification.Service, *models.User, []string) {
|
||
t.Helper()
|
||
recoveryService, verifyService, engine, user := newRecoveryFixture(t)
|
||
user.ApplyRole(access.RoleAdmin)
|
||
if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
codes, err := recoveryService.credentials.GenerateRecoveryCodes(user.ID, 5)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return recoveryService, verifyService, user, codes
|
||
}
|
||
|
||
t.Run("rejects missing offline code", func(t *testing.T) {
|
||
recoveryService, verifyService, user, _ := setup(t)
|
||
var verificationCode string
|
||
verifyService.Send = func(_ verification.Challenge, issued string) error {
|
||
verificationCode = issued
|
||
return nil
|
||
}
|
||
challengeID, err := recoveryService.Begin(BeginRequest{Identifier: user.Account, Email: "alice@example.com"}, "192.0.2.4")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := recoveryService.Verify(challengeID, user.Account, "alice@example.com", verificationCode, "", "192.0.2.4"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("administrator recovery without offline code error = %v", err)
|
||
}
|
||
})
|
||
|
||
t.Run("accepts and consumes valid offline code", func(t *testing.T) {
|
||
recoveryService, verifyService, user, codes := setup(t)
|
||
var verificationCode string
|
||
verifyService.Send = func(_ verification.Challenge, issued string) error {
|
||
verificationCode = issued
|
||
return nil
|
||
}
|
||
challengeID, err := recoveryService.Begin(BeginRequest{Identifier: user.Account, Email: "alice@example.com"}, "192.0.2.5")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ticketID, err := recoveryService.Verify(challengeID, user.Account, "alice@example.com", verificationCode, codes[0], "192.0.2.5")
|
||
if err != nil || ticketID == "" {
|
||
t.Fatalf("administrator combined recovery failed: ticket=%q err=%v", ticketID, err)
|
||
}
|
||
if err := recoveryService.credentials.ConsumeRecoveryCode(user.ID, codes[0]); !errors.Is(err, credentials.ErrCodeNotFound) {
|
||
t.Fatalf("offline recovery code was not consumed: %v", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestOfflineRecoveryRequiresMatchingQuestionAnswerAndOneTimeCode(t *testing.T) {
|
||
service, _, _, user := newRecoveryFixture(t)
|
||
if err := service.credentials.SetSecurityQuestion(user.ID, "First pet name?", "BlueBird123"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := service.VerifyOffline(user.Account, "Wrong question?", "BlueBird123", codes[0], "192.0.2.30"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("offline recovery with wrong question error = %v", err)
|
||
}
|
||
if _, err := service.VerifyOffline(user.Account, "First pet name?", "wrong-answer", codes[0], "192.0.2.30"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("offline recovery with wrong answer error = %v", err)
|
||
}
|
||
ticketID, err := service.VerifyOffline(user.Account, "First pet name?", " bluebird123 ", codes[0], "192.0.2.30")
|
||
if err != nil || ticketID == "" {
|
||
t.Fatalf("offline recovery failed: ticket=%q err=%v", ticketID, err)
|
||
}
|
||
if _, err := service.VerifyOffline(user.Account, "First pet name?", "BlueBird123", codes[0], "192.0.2.30"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("reused offline recovery code error = %v", err)
|
||
}
|
||
}
|
||
func TestInvalidateUserRevokesOnlyTargetRecoveryTickets(t *testing.T) {
|
||
service, _, _, user := newRecoveryFixture(t)
|
||
targetTicket, err := service.issueTicket(user.ID, user.SessionVersion)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
otherTicket, err := service.issueTicket(user.ID+1, user.SessionVersion)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service.InvalidateUser(user.ID)
|
||
service.mu.Lock()
|
||
_, targetExists := service.tickets[ticketKey(targetTicket)]
|
||
_, otherExists := service.tickets[ticketKey(otherTicket)]
|
||
service.mu.Unlock()
|
||
if targetExists || !otherExists {
|
||
t.Fatalf("unexpected ticket state: target=%v other=%v", targetExists, otherExists)
|
||
}
|
||
}
|
||
|
||
func TestRecoveryStoresOnlyTicketDigestAndLimitsCapacity(t *testing.T) {
|
||
service, _, _, user := newRecoveryFixture(t)
|
||
ticketID, err := service.issueTicket(user.ID, user.SessionVersion)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service.mu.Lock()
|
||
_, rawStored := service.tickets[ticketID]
|
||
_, digestStored := service.tickets[ticketKey(ticketID)]
|
||
service.mu.Unlock()
|
||
if rawStored || !digestStored {
|
||
t.Fatalf("票据存储边界错误:raw=%v digest=%v", rawStored, digestStored)
|
||
}
|
||
|
||
service.mu.Lock()
|
||
service.tickets = make(map[string]ticket, maxRecoveryTickets)
|
||
for index := 0; index < maxRecoveryTickets; index++ {
|
||
service.tickets[string(rune(index+1))] = ticket{UserID: user.ID, SessionVersion: user.SessionVersion, ExpiresAt: service.now().Add(authorizationTTL)}
|
||
}
|
||
service.mu.Unlock()
|
||
if _, err := service.issueTicket(user.ID, user.SessionVersion); !errors.Is(err, ErrUnavailable) {
|
||
t.Fatalf("票据容量达到上限后的错误 = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRecoveryRevokesExistingStrongRecoveryCredentials(t *testing.T) {
|
||
service, verifyService, engine, user := newRecoveryFixture(t)
|
||
codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.credentials.SetSecurityQuestion(user.ID, "常用登录地点是什么", "Shanghai-2026"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
credential := &models.WebAuthnCredential{
|
||
UserID: user.ID, CredentialID: "existing-security-key", CredentialData: "encrypted",
|
||
DisplayName: "现有安全密钥", CreatedAt: service.now(),
|
||
}
|
||
if _, err := engine.Insert(credential); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var code string
|
||
verifyService.Send = func(_ verification.Challenge, issued string) error { code = issued; return nil }
|
||
challengeID, err := service.Begin(BeginRequest{Identifier: user.Account, Email: "alice@example.com"}, "192.0.2.20")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ticketID, err := service.Verify(challengeID, user.Account, "alice@example.com", code, "", "192.0.2.20")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.Complete(ticketID, "RecoverP123"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if configured, err := service.credentials.HasValidRecoveryCode(user.ID); err != nil || configured {
|
||
t.Fatalf("有效离线恢复码未保留:configured=%v err=%v", configured, err)
|
||
}
|
||
if err := service.credentials.ConsumeRecoveryCode(user.ID, codes[0]); err == nil {
|
||
t.Fatalf("原离线恢复码不可用:%v", err)
|
||
}
|
||
if question, configured, err := service.credentials.GetSecurityQuestion(user.ID); err != nil || configured || question != "" {
|
||
t.Fatalf("密保问题未保留:question=%q configured=%v err=%v", question, configured, err)
|
||
}
|
||
var stored models.WebAuthnCredential
|
||
if found, err := engine.ID(credential.ID).Get(&stored); err != nil || !found || stored.RevokedAt == nil {
|
||
t.Fatalf("WebAuthn 凭据未保留:found=%v credential=%+v err=%v", found, stored, err)
|
||
}
|
||
}
|
||
|
||
func TestRecoveryTicketRejectsChangedSessionVersion(t *testing.T) {
|
||
service, _, engine, user := newRecoveryFixture(t)
|
||
ticketID, err := service.issueTicket(user.ID, user.SessionVersion)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := engine.ID(user.ID).Cols("session_version").Update(&models.User{SessionVersion: user.SessionVersion + 1}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.Complete(ticketID, "VersionP123"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("版本变化后的票据错误 = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRecoveryBindsChallengeAndTicketToBrowserContext(t *testing.T) {
|
||
service, verifyService, _, user := newRecoveryFixture(t)
|
||
var code string
|
||
verifyService.Send = func(_ verification.Challenge, issued string) error {
|
||
code = issued
|
||
return nil
|
||
}
|
||
challengeID, err := service.Begin(BeginRequest{
|
||
Identifier: user.Account,
|
||
Email: "alice@example.com",
|
||
BrowserContext: "browser-a",
|
||
}, "192.0.2.40")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := service.VerifyWithContext(challengeID, user.Account, "alice@example.com", code, "", "192.0.2.40", "browser-b"); !errors.Is(err, verification.ErrInvalidChallenge) {
|
||
t.Fatalf("different browser challenge result = %v", err)
|
||
}
|
||
ticketID, err := service.VerifyWithContext(challengeID, user.Account, "alice@example.com", code, "", "192.0.2.40", "browser-a")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := service.CompleteWithContext(ticketID, "ContextP123", "browser-b"); !errors.Is(err, ErrRecoveryDenied) {
|
||
t.Fatalf("different browser ticket result = %v", err)
|
||
}
|
||
if err := service.CompleteWithContext(ticketID, "ContextP123", "browser-a"); err != nil {
|
||
t.Fatalf("matching browser ticket result = %v", err)
|
||
}
|
||
}
|