From 99987a63bf3ef253976e38eefc561df23fb55d7e Mon Sep 17 00:00:00 2001 From: DiheChen Date: Sun, 8 Nov 2026 23:41:17 +0800 Subject: [PATCH] fix: sign in and sign out succeed when the disconnect notice for this device's own session fails --- internal/adapter/in/api/api_test.go | 2 +- internal/app/identity/account.go | 18 +++++++--- internal/app/identity/account_test.go | 52 +++++++++++++++++++++------ internal/bootstrap/public.go | 2 +- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index b0eab61..f8613fd 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -42,7 +42,7 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string l := lobby.New(rooms, node) return Services{ Identity: identity.New(store, rooms, store, gateway), - Accounts: identity.NewAccounts(store, store, nil, gateway), + Accounts: identity.NewAccounts(store, store, nil, gateway, slog.New(slog.DiscardHandler)), Lobby: l, Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway), Gateway: gateway, diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go index 795e6a4..2788bdb 100644 --- a/internal/app/identity/account.go +++ b/internal/app/identity/account.go @@ -3,6 +3,7 @@ package identity import ( "context" "errors" + "log/slog" "time" "github.com/dihemoe/alisten/internal/app/port" @@ -36,16 +37,18 @@ type Account struct { Username string } -// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误。 +// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误, +// 只有撤销发起请求的这台设备自己的会话(Login、Logout)例外,只记日志。 type Accounts struct { members port.Members site port.Site quota port.AccountQuota gateway *realtime.Gateway + logger *slog.Logger } -func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, gateway *realtime.Gateway) *Accounts { - return &Accounts{members: members, site: site, quota: quota, gateway: gateway} +func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, gateway *realtime.Gateway, logger *slog.Logger) *Accounts { + return &Accounts{members: members, site: site, quota: quota, gateway: gateway, logger: logger} } // 游客没有账号,ok 为 false。 @@ -108,8 +111,9 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke case err != nil: return Session{}, Account{}, err } + // 页面随后会重新加载,游客的连接即使没断开也不再使用;此时返回错误会让用户拿不到已经签发的新会话。 if err := a.gateway.RevokeSessions(ctx, [][]byte{guest}); err != nil { - return Session{}, Account{}, err + a.logger.WarnContext(ctx, "登录后断开游客会话的通知没有发出", "error", err) } return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username}, nil } @@ -144,7 +148,11 @@ func (a *Accounts) Logout(ctx context.Context, token string) error { if err := a.members.DeleteSession(ctx, hash); err != nil { return err } - return a.gateway.RevokeSessions(ctx, [][]byte{hash}) + // 页面随后会重新加载,不因通知没有发出而让退出失败。 + if err := a.gateway.RevokeSessions(ctx, [][]byte{hash}); err != nil { + a.logger.WarnContext(ctx, "退出后断开会话的通知没有发出", "error", err) + } + return nil } func (a *Accounts) LogoutOthers(ctx context.Context, memberID, token string) error { diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go index 417216d..13b880f 100644 --- a/internal/app/identity/account_test.go +++ b/internal/app/identity/account_test.go @@ -1,9 +1,11 @@ package identity import ( + "bytes" "context" "errors" "fmt" + "log/slog" "regexp" "slices" "sync" @@ -62,7 +64,7 @@ func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store, } } } - return NewAccounts(store, store, q, gateway), New(store, store, store, gateway), store, q, revoked + return NewAccounts(store, store, q, gateway, slog.New(slog.DiscardHandler)), New(store, store, store, gateway), store, q, revoked } // 不计顺序。 @@ -377,22 +379,19 @@ func (noRevocation) PublishSessionsRevoked(context.Context, [][]byte) error { return porttest.ErrRedisDown } -// 会话已经删除而断开连接的通知没有发出时报错,与重置 API Key 的约定一致。 -func TestAccountOperationsFailWhenRevocationFails(t *testing.T) { +// 撤销的是别的设备的会话:断开的通知没有发出时报错,让用户知道它们没有被踢出去。 +func TestOtherDeviceOperationsFailWhenRevocationFails(t *testing.T) { store := porttest.New() node := porttest.NewRedis().Node("a") gateway := newGateway(store, node, noRevocation{node}) s := New(store, store, store, gateway) - a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway) + a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway, slog.New(slog.DiscardHandler)) alice := registerAccount(t, a, s, "alice", "correct horse") login := func() { t.Helper() guest := createMember(t, s) - if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, realtime.ErrUnavailable) { - t.Errorf("广播失败时 Login = %v,期望 realtime.ErrUnavailable", err) - } - if _, ok := tokenOwner(t, store, guest.MemberToken); ok { - t.Error("广播失败时游客原来的会话也应已经删除") + if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); err != nil { + t.Fatal(err) } } // 每一步之前都先登录一次,让这一步有会话可删。 @@ -404,7 +403,6 @@ func TestAccountOperationsFailWhenRevocationFails(t *testing.T) { {"ChangePassword", func() error { return a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse") }}, - {"Logout", func() error { return a.Logout(t.Context(), alice.MemberToken) }}, {"ResetPassword", func() error { _, err := a.ResetPassword(t.Context(), "alice") return err @@ -417,6 +415,38 @@ func TestAccountOperationsFailWhenRevocationFails(t *testing.T) { } } +// 登录和退出撤销的是发起请求的这台设备的会话,页面随后会重新加载:广播失败时只记日志,照常成功。 +func TestLoginAndLogoutSucceedWhenRevocationFails(t *testing.T) { + store := porttest.New() + node := porttest.NewRedis().Node("a") + gateway := newGateway(store, node, noRevocation{node}) + s := New(store, store, store, gateway) + var logs bytes.Buffer + a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway, slog.New(slog.NewJSONHandler(&logs, nil))) + alice := registerAccount(t, a, s, "alice", "correct horse") + + guest := createMember(t, s) + session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse") + if err != nil { + t.Fatalf("广播失败时 Login = %v,期望成功", err) + } + if _, ok := tokenOwner(t, store, guest.MemberToken); ok { + t.Error("游客原来的会话应已经删除") + } + if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m.ID != alice.Member.ID { + t.Error("新会话应已经签发") + } + if err := a.Logout(t.Context(), session.MemberToken); err != nil { + t.Fatalf("广播失败时 Logout = %v,期望成功", err) + } + if _, ok := tokenOwner(t, store, session.MemberToken); ok { + t.Error("退出的会话应已经删除") + } + if n := bytes.Count(logs.Bytes(), []byte(`"level":"WARN"`)); n != 2 { + t.Errorf("警告日志 %d 条,期望 Login 和 Logout 各 1 条:\n%s", n, logs.String()) + } +} + // 在登录校验密码之后、签发新会话之前修改账号的密码。 type passwordChangedMembers struct { *porttest.Store @@ -441,7 +471,7 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) { } node := porttest.NewRedis().Node("a") - changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node)) + changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler)) guest = createMember(t, s) if _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) { t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err) diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index c4b502d..51eab44 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -78,7 +78,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc rooms := lobby.New(store, leases) services := api.Services{ Identity: identity.New(store, store, store, gateway), - Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway), + Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")), Lobby: rooms, Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway), Gateway: gateway,