This commit is contained in:
parent
a60898d682
commit
4c9490c6c5
224
backend/services/adminreset/reset_integration_linux_test.go
Normal file
224
backend/services/adminreset/reset_integration_linux_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user