fix: sign in and sign out succeed when the disconnect notice for this device's own session fails
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s

This commit is contained in:
DiheChen committed 2026-11-08 23:41:17 +08:00
1 parent 5ee219caba
commit 99987a63bf
4 files changed
+56 -18

No files matched your search

+1 -1
View File
@@ -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,
+13 -5
View File
@@ -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 {
+41 -11
View File
@@ -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)
+1 -1
View File
@@ -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,