feat: bind room tickets to member sessions and disconnect revoked sessions

This commit is contained in:
DiheChen committed 2026-11-09 21:24:32 +08:00
1 parent a80c48824f
commit f0f38ff511
51 files changed
+1404 -230

No files matched your search

+24
View File
@@ -11,6 +11,14 @@ type Handler = (token: string | null) => Response | Promise<Response>
class FakeStorage {
readonly #items = new Map<string, string>()
get length(): number {
return this.#items.size
}
key(index: number): string | null {
return [...this.#items.keys()][index] ?? null
}
getItem(key: string): string | null {
return this.#items.get(key) ?? null
}
@@ -172,6 +180,18 @@ describe('token 失效时更换身份', () => {
expect(events).toEqual([{ kind: 'rejected' }, { kind: 'renewed', member: issued }])
})
it('票据属于被拒绝的身份,一并删除', async () => {
local.setItem(memberTokenKey, 'expired')
local.setItem('alisten.ticket.r1', 't1')
local.setItem('alisten.ticket.r2', 't2')
routes.set('PATCH /api/me', unauthorized)
const { api, IdentityRenewedError } = await load()
await expect(api.updateMe({ name: 'alice' })).rejects.toBeInstanceOf(IdentityRenewedError)
expect(local.getItem('alisten.ticket.r1')).toBeNull()
expect(local.getItem('alisten.ticket.r2')).toBeNull()
})
it('同时被拒绝的请求只更换一次身份', async () => {
local.setItem(memberTokenKey, 'expired')
routes.set('PATCH /api/me', unauthorized)
@@ -202,6 +222,7 @@ describe('token 失效时更换身份', () => {
it('其他标签页已经换了 token 时不删除新的 token', async () => {
local.setItem(memberTokenKey, 'expired')
local.setItem('alisten.ticket.r1', 't1')
routes.set('PATCH /api/me', () => {
local.setItem(memberTokenKey, 'good')
return unauthorized()
@@ -210,6 +231,7 @@ describe('token 失效时更换身份', () => {
await expect(api.updateMe({ name: 'a' })).rejects.toThrow('刚才的操作没有生效')
expect(local.getItem(memberTokenKey)).toBe('good')
expect(local.getItem('alisten.ticket.r1')).toBe('t1')
expect(callsTo('POST /api/members')).toHaveLength(0)
expect(events).toEqual([{ kind: 'rejected' }, { kind: 'renewed', member }])
})
@@ -272,6 +294,7 @@ describe('token 失效时更换身份', () => {
it('修改密码时当前密码错误返回 INVALID_CREDENTIALS,不更换身份', async () => {
local.setItem(memberTokenKey, 'good')
local.setItem('alisten.ticket.r1', 't1')
routes.set('PUT /api/account/password', () => errorResponse(401, 'INVALID_CREDENTIALS', '当前密码错误'))
const { api, events, IdentityRenewedError } = await load()
const error: unknown = await api.changePassword({ currentPassword: 'wrong horse', newPassword: 'correct horse' }).catch((reason: unknown) => reason)
@@ -279,6 +302,7 @@ describe('token 失效时更换身份', () => {
expect(error).toMatchObject({ code: 'INVALID_CREDENTIALS', status: 401 })
expect(events).toEqual([])
expect(local.getItem(memberTokenKey)).toBe('good')
expect(local.getItem('alisten.ticket.r1')).toBe('t1')
})
it('账号密码错误不更换身份', async () => {
+5 -3
View File
@@ -1,7 +1,7 @@
import type { Auth, CreateTicketRequest, CreateTicketResponse, ErrorResponse, HttpRequest } from './http.gen'
import type { ErrorCode, Member } from '@/core/realtime'
import { Emitter } from '@/shared/lib/emitter'
import { deleteMemberToken, loadAdminToken, loadMemberToken, memberTokenKey, saveMemberToken } from './credentials'
import { deleteAllTickets, deleteMemberToken, loadAdminToken, loadMemberToken, memberTokenKey, saveMemberToken } from './credentials'
import { httpClient } from './http.gen'
export class ApiError extends Error {
@@ -163,9 +163,11 @@ function renewIdentity(rejected: string): Promise<void> {
// 更换过程中用 unrenewed 读取成员:新 token 也被拒绝时不再更换,避免循环。
async function replaceIdentity(rejected: string): Promise<void> {
// 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。
if (loadMemberToken() === rejected)
// 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。票据属于被拒绝的身份,一并删除。
if (loadMemberToken() === rejected) {
deleteMemberToken()
deleteAllTickets()
}
identityEvents.emit({ kind: 'rejected' })
let member: Member
try {
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = 'bb812527ea1133df'
export const PROTOCOL_VERSION = '000a0b37ea0f0adc'
export interface APIKey {
key: string
@@ -47,7 +47,7 @@ export function RoomApiKeyDialog({ room, onClose }: { room: RoomListing, onClose
setStatus(undefined)
setAttempt(n => n + 1)
}
// 失败时 Key 可能已经重置,只是没能通知各实例断开旧 Key 的事件流,所以重新获取状态。
// 请求失败时服务端仍可能已经重置了 Key(比如响应没有送达),所以重新获取状态。
const renew = async (): Promise<string | null> => {
const result = await adminStore.call(() => api.adminResetRoomApiKey(room.id))
if (!result)