fix(gateway): смена пароля не закрывает сессию текущего устройства
ChangePassword отзывает остальные сессии, но обработчик закрывал соединения всех устройств пользователя — включая то, с которого пароль сменили: страница теряла шлюз в момент успешного ответа, и подтверждение «Пароль изменён» не показывалось (пункт 12 матрицы 11.6, меняли пароль через интерфейс). Добавлен InvalidateUserExcept: текущее соединение опознаётся по хэшу токена сессии, который теперь хранит и буфер RESUME. Для logout-all, бана и админского сброса поведение прежнее — закрываются все соединения. Тест: TestInvalidateUserExceptKeepsCurrentSession (второе устройство получает INVALID_SESSION, текущее продолжает отвечать на heartbeat).
This commit is contained in:
@@ -396,12 +396,29 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
|
|||||||
// (logout-all, смена пароля, действия администратора) — иначе второе
|
// (logout-all, смена пароля, действия администратора) — иначе второе
|
||||||
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
|
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
|
||||||
func (s *Service) InvalidateUser(userID uint64, reason string) {
|
func (s *Service) InvalidateUser(userID uint64, reason string) {
|
||||||
|
s.invalidateUser(userID, "", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateUserExcept закрывает соединения пользователя, кроме текущего:
|
||||||
|
// смена пароля отзывает остальные сессии, но устройство, с которого её
|
||||||
|
// сменили, продолжает работать (AGENT.md 7.1, 11.6).
|
||||||
|
func (s *Service) InvalidateUserExcept(userID uint64, keepTokenHash, reason string) {
|
||||||
|
s.invalidateUser(userID, keepTokenHash, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) invalidateUser(userID uint64, keepTokenHash, reason string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
targets := make([]*clientSession, 0, 2)
|
targets := make([]*clientSession, 0, 2)
|
||||||
for _, session := range s.sessions {
|
for _, session := range s.sessions {
|
||||||
if session.userID == userID {
|
if session.userID != userID {
|
||||||
targets = append(targets, session)
|
continue
|
||||||
}
|
}
|
||||||
|
// Соединение опознаём по хэшу токена сессии: у каждой сессии свой буфер
|
||||||
|
// RESUME, он же хранит владельца (AGENT.md 8.3).
|
||||||
|
if keepTokenHash != "" && session.buffer != nil && session.buffer.tokenHash == keepTokenHash {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, session)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
for _, session := range targets {
|
for _, session := range targets {
|
||||||
|
|||||||
@@ -424,6 +424,56 @@ func TestDispatchReachesConnectedClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInvalidateUserExceptKeepsCurrentSession: смена пароля отзывает остальные
|
||||||
|
// сессии, но соединение устройства, с которого её сменили, остаётся живым,
|
||||||
|
// иначе пользователь терял бы интерфейс в момент успешной смены (AGENT.md 7.1).
|
||||||
|
func TestInvalidateUserExceptKeepsCurrentSession(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
token := registerUser(t, f, "except_user", "except@example.com")
|
||||||
|
// Вторая сессия того же пользователя: вход тем же паролем с другого
|
||||||
|
// «устройства» (в тесте — второй токен).
|
||||||
|
user, currentSession, err := f.auth.ResolveSession(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveSession: %v", err)
|
||||||
|
}
|
||||||
|
email, err := f.auth.Email(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Email: %v", err)
|
||||||
|
}
|
||||||
|
_, otherToken, otherSession, err := f.auth.Login(ctx, auth.LoginInput{
|
||||||
|
Email: email, Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentConn, _ := f.dial(t)
|
||||||
|
send(t, currentConn, gateway.OpIdentify, map[string]any{"token": token})
|
||||||
|
if ready := readEnvelope(t, currentConn); ready.T != "READY" {
|
||||||
|
t.Fatalf("expected READY, got %q", ready.T)
|
||||||
|
}
|
||||||
|
otherConn, _ := f.dial(t)
|
||||||
|
send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken})
|
||||||
|
if ready := readEnvelope(t, otherConn); ready.T != "READY" {
|
||||||
|
t.Fatalf("expected READY for the second device, got %q", ready.T)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.service.InvalidateUserExcept(user.ID, currentSession.TokenHash, "password_changed")
|
||||||
|
|
||||||
|
// Второе устройство получает INVALID_SESSION...
|
||||||
|
invalid := readEnvelope(t, otherConn)
|
||||||
|
if invalid.Op != gateway.OpInvalidSess {
|
||||||
|
t.Fatalf("other device op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
|
||||||
|
}
|
||||||
|
// ...а текущее продолжает работать: heartbeat отвечает.
|
||||||
|
send(t, currentConn, gateway.OpHeartbeat, nil)
|
||||||
|
if ack := readEnvelope(t, currentConn); ack.Op != gateway.OpHeartbeatAck {
|
||||||
|
t.Fatalf("current device op = %d, want HEARTBEAT_ACK", ack.Op)
|
||||||
|
}
|
||||||
|
_ = otherSession
|
||||||
|
}
|
||||||
|
|
||||||
func TestResumeReplaysMissedEvents(t *testing.T) {
|
func TestResumeReplaysMissedEvents(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
token := registerUser(t, f, "resume_user", "resume@example.com")
|
token := registerUser(t, f, "resume_user", "resume@example.com")
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ func (s *Service) register(session *clientSession, tokenHash string) {
|
|||||||
s.sessions[session.id] = session
|
s.sessions[session.id] = session
|
||||||
buffer, ok := s.buffers[tokenHash]
|
buffer, ok := s.buffers[tokenHash]
|
||||||
if !ok {
|
if !ok {
|
||||||
buffer = newResumeBuffer(ResumeBufferSize)
|
buffer = newResumeBuffer(ResumeBufferSize, tokenHash)
|
||||||
s.buffers[tokenHash] = buffer
|
s.buffers[tokenHash] = buffer
|
||||||
}
|
}
|
||||||
// Владелец буфера нужен, чтобы доставлять адресные события (SendToUser)
|
// Владелец буфера нужен, чтобы доставлять адресные события (SendToUser)
|
||||||
@@ -347,7 +347,7 @@ func (s *Service) bufferFor(tokenHash string) *resumeBuffer {
|
|||||||
if buffer, ok := s.buffers[tokenHash]; ok {
|
if buffer, ok := s.buffers[tokenHash]; ok {
|
||||||
return buffer
|
return buffer
|
||||||
}
|
}
|
||||||
buffer := newResumeBuffer(ResumeBufferSize)
|
buffer := newResumeBuffer(ResumeBufferSize, tokenHash)
|
||||||
s.buffers[tokenHash] = buffer
|
s.buffers[tokenHash] = buffer
|
||||||
return buffer
|
return buffer
|
||||||
}
|
}
|
||||||
@@ -405,11 +405,14 @@ func newSessionID() string {
|
|||||||
|
|
||||||
// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3).
|
// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3).
|
||||||
type resumeBuffer struct {
|
type resumeBuffer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
owner uint64
|
// tokenHash — сессия пользователя, которой принадлежит буфер: по нему
|
||||||
limit int
|
// адресные отзывы отличают текущее соединение от остальных.
|
||||||
items []bufferedEvent
|
tokenHash string
|
||||||
updated time.Time
|
owner uint64
|
||||||
|
limit int
|
||||||
|
items []bufferedEvent
|
||||||
|
updated time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type bufferedEvent struct {
|
type bufferedEvent struct {
|
||||||
@@ -417,8 +420,8 @@ type bufferedEvent struct {
|
|||||||
payload []byte
|
payload []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newResumeBuffer(limit int) *resumeBuffer {
|
func newResumeBuffer(limit int, tokenHash string) *resumeBuffer {
|
||||||
return &resumeBuffer{limit: limit, updated: time.Now()}
|
return &resumeBuffer{limit: limit, tokenHash: tokenHash, updated: time.Now()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *resumeBuffer) append(seq int64, payload []byte) {
|
func (b *resumeBuffer) append(seq int64, payload []byte) {
|
||||||
|
|||||||
@@ -270,9 +270,11 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
|||||||
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
// Смена пароля отзывает остальные сессии: соединения закрываем сразу.
|
// Смена пароля отзывает остальные сессии: их соединения закрываем сразу,
|
||||||
|
// а устройство, с которого пароль сменили, продолжает работать
|
||||||
|
// (AGENT.md 7.1, 11.6).
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
s.gateway.InvalidateUser(user.ID, "password_changed")
|
s.gateway.InvalidateUserExcept(user.ID, session.TokenHash, "password_changed")
|
||||||
}
|
}
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user