diff --git a/backend/services/adminreset/reset_integration_linux_test.go b/backend/services/adminreset/reset_integration_linux_test.go new file mode 100644 index 0000000..ab6b466 --- /dev/null +++ b/backend/services/adminreset/reset_integration_linux_test.go @@ -0,0 +1,224 @@ +//go:build linux + +package adminreset + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/utils/password" + "xorm.io/xorm" +) + +const integrationOldPassword = "OldPass123" +const integrationNewPassword = "NewPass456" + +func resetIntegrationDatabase(t *testing.T) (*xorm.Engine, []models.User) { + t.Helper() + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "reset.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + if err := engine.Sync2(&models.User{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, + &models.WebAuthnCredential{}, &models.ProtocolCredential{}); err != nil { + t.Fatal(err) + } + hash, err := password.HashUserPassword(integrationOldPassword) + if err != nil { + t.Fatal(err) + } + users := []models.User{ + {Account: "root", Name: "恢复管理员", Role: "super_admin", IsAdmin: 1, + Password: hash, SessionVersion: 3, SecurityVersion: 5, MustChangePassword: 1}, + {Account: "alice", Name: "普通用户", Role: "user", + Password: hash, SessionVersion: 7, SecurityVersion: 9}, + } + for index := range users { + user := &users[index] + if _, err := engine.Insert(user); err != nil { + t.Fatal(err) + } + if _, err := engine.Insert( + &models.SecurityQuestion{UserID: user.ID, Question: "测试问题", AnswerHash: hash, Version: 1}, + &models.RecoveryCode{UserID: user.ID, CodeHash: hash, CodeVersion: 1}, + &models.WebAuthnCredential{UserID: user.ID, CredentialID: user.Account + "-key", + CredentialData: "test-data", DisplayName: "测试密钥", CreatedAt: time.Now()}, + ); err != nil { + t.Fatal(err) + } + for _, protocol := range credentials.SupportedProtocols() { + if _, err := engine.Insert(&models.ProtocolCredential{UserID: user.ID, Protocol: protocol, + Enabled: true, CodeHash: hash, CodeVersion: 1}); err != nil { + t.Fatal(err) + } + } + } + return engine, users +} + +func assertResetCredentialState(t *testing.T, engine *xorm.Engine, userID int, revoked bool) { + t.Helper() + want := int64(1) + if revoked { + want = 0 + } + for _, query := range []struct { + bean any + where string + count int64 + }{ + {&models.SecurityQuestion{}, "user_id = ?", want}, + {&models.RecoveryCode{}, "user_id = ? AND used_at IS NULL", want}, + {&models.WebAuthnCredential{}, "user_id = ? AND revoked_at IS NULL", want}, + {&models.ProtocolCredential{}, "user_id = ? AND revoked_at IS NULL", 3 * want}, + } { + count, err := engine.Where(query.where, userID).Count(query.bean) + if err != nil || count != query.count { + t.Fatalf("用户 %d 的 %T 有效数量 = %d,预期 %d,错误:%v", userID, query.bean, count, query.count, err) + } + } +} + +func assertResetUserUnchanged(t *testing.T, engine *xorm.Engine, original models.User) { + t.Helper() + var actual models.User + found, err := engine.ID(original.ID).Get(&actual) + if err != nil || !found { + t.Fatalf("读取原用户失败:%v", err) + } + if actual.Password != original.Password || actual.SessionVersion != original.SessionVersion || + actual.SecurityVersion != original.SecurityVersion || actual.MustChangePassword != original.MustChangePassword || + actual.PasswordChangedAt != nil { + t.Fatal("失败重置或其他用户的密码与安全状态被修改") + } + assertResetCredentialState(t, engine, original.ID, false) +} + +func TestUnixResetCommitsPasswordAndRevokesAllProtocolCodes(t *testing.T) { + engine, users := resetIntegrationDatabase(t) + service, err := credentials.NewService(engine) + if err != nil { + t.Fatal(err) + } + for _, protocol := range credentials.SupportedProtocols() { + if matched, err := service.CheckProtocolCode(users[0].ID, protocol, integrationOldPassword); err != nil || !matched { + t.Fatalf("重置前协议 %s 的凭据不可用:%v", protocol, err) + } + } + path := filepath.Join(t.TempDir(), "control.sock") + // 隔离夹具仅信任测试进程的 UID;不改变生产入口只信任 root 的配置。 + server, err := ListenUnix(path, []uint32{uint32(os.Geteuid())}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + done := make(chan error, 1) + invalidated := make(chan bool, 1) + go func() { + done <- server.Serve(ctx, func(value string) error { + return ResetSuperAdmin(engine, value, func(id int, account string) { + var committed models.User + found, err := engine.ID(id).Get(&committed) + invalidated <- err == nil && found && id == users[0].ID && account == users[0].Account && + committed.SessionVersion == users[0].SessionVersion+1 + }) + }, NewSessionGuard(time.Minute)) + }() + t.Cleanup(func() { + cancel() + _ = server.Close() + select { + case err := <-done: + if err != nil { + t.Errorf("隔离控制服务退出失败:%v", err) + } + case <-time.After(2 * time.Second): + t.Error("隔离控制服务没有退出") + } + }) + conn, err := DialControl(ctx, path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if err := SendReset(ctx, conn, integrationNewPassword); err != nil { + t.Fatal(err) + } + select { + case committed := <-invalidated: + if !committed { + t.Fatal("运行态撤销未在目标用户事务提交后执行") + } + default: + t.Fatal("未执行运行态撤销") + } + var updated models.User + if found, err := engine.ID(users[0].ID).Get(&updated); err != nil || !found { + t.Fatalf("读取更新后的管理员失败:%v", err) + } + if !password.Verify(updated.Password, integrationNewPassword) || password.Verify(updated.Password, integrationOldPassword) { + t.Fatal("新旧密码验证结果错误") + } + if updated.SessionVersion != 4 || updated.SecurityVersion != 6 || updated.MustChangePassword != 0 || updated.PasswordChangedAt == nil { + t.Fatal("密码重置的会话版本、安全版本或密码状态未提交") + } + assertResetCredentialState(t, engine, users[0].ID, true) + assertResetUserUnchanged(t, engine, users[1]) + for _, protocol := range credentials.SupportedProtocols() { + for index, user := range users { + matched, err := service.CheckProtocolCode(user.ID, protocol, integrationOldPassword) + if err != nil || matched != (index == 1) { + t.Fatalf("重置后用户 %d 协议 %s 的凭据验证结果错误:%v", user.ID, protocol, err) + } + } + } +} + +func TestResetSuperAdminRollsBackEveryRevocationStage(t *testing.T) { + for _, stage := range []struct{ table, operation string }{ + {"recovery_code", "UPDATE"}, {"security_question", "DELETE"}, + {"webauthn_credential", "UPDATE"}, {"protocol_credential", "UPDATE"}, + } { + t.Run(stage.table, func(t *testing.T) { + engine, users := resetIntegrationDatabase(t) + // 在真实事务的不同阶段拒绝写入,包含前面多步已成功的最终撤销阶段。 + query := "CREATE TRIGGER reject_revocation BEFORE " + stage.operation + " ON " + stage.table + + " BEGIN SELECT RAISE(ABORT, 'injected revocation failure'); END" + if _, err := engine.Exec(query); err != nil { + t.Fatal(err) + } + invalidated := false + err := ResetSuperAdmin(engine, integrationNewPassword, func(int, string) { invalidated = true }) + if err == nil || !strings.Contains(err.Error(), "injected revocation failure") || invalidated { + t.Fatalf("没有命中指定撤销故障,或提前撤销运行态:%v", err) + } + for _, user := range users { + assertResetUserUnchanged(t, engine, user) + } + }) + } +} + +func TestResetSuperAdminRejectsAmbiguousTargetWithoutChanges(t *testing.T) { + engine, users := resetIntegrationDatabase(t) + users[1].Role, users[1].IsAdmin = "super_admin", 1 + if _, err := engine.ID(users[1].ID).Cols("role", "is_admin").Update(&users[1]); err != nil { + t.Fatal(err) + } + invalidated := false + err := ResetSuperAdmin(engine, integrationNewPassword, func(int, string) { invalidated = true }) + if !errors.Is(err, ErrSuperAdminAmbiguous) || invalidated { + t.Fatal("多个超级管理员时未安全拒绝") + } + for _, user := range users { + assertResetUserUnchanged(t, engine, user) + } +}