fix(auth): tighten refresh session revocation

This commit is contained in:
2026-05-13 15:04:37 +08:00
parent b13870f71b
commit 4fecf9c975
36 changed files with 1664 additions and 170 deletions

View File

@@ -31,6 +31,8 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds?: string[];
initialSection?:
| 'appearance'
| 'account'
@@ -63,7 +65,10 @@ function renderAccountModal(overrides?: {
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
onRevokeSession={
overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined)
}
revokingSessionIds={overrides?.revokingSessionIds ?? []}
changePhoneCaptchaChallenge={null}
onSendChangePhoneCode={vi.fn().mockResolvedValue({
cooldownSeconds: 60,
@@ -75,6 +80,30 @@ function renderAccountModal(overrides?: {
);
}
function buildSession(
overrides: Partial<AuthSessionSummary> = {},
): AuthSessionSummary {
return {
sessionId: 'usess_1',
sessionIds: ['usess_1'],
sessionCount: 1,
clientType: 'web_browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: 'Windows / Chrome',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
ipMasked: '203.0.*.*',
isCurrent: false,
createdAt: '2026-05-01T10:00:00.000Z',
lastSeenAt: '2026-05-01T10:30:00.000Z',
expiresAt: '2026-06-01T10:30:00.000Z',
...overrides,
};
}
test('settings header uses a generic title instead of the phone number', () => {
renderAccountModal();
@@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async
},
],
sessions: [
{
buildSession({
sessionId: 'session-1',
sessionIds: ['session-1'],
sessionCount: 1,
clientType: 'mobile',
clientRuntime: 'ios',
clientPlatform: 'wechat',
@@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
}),
],
auditLogs: [
{
@@ -294,3 +325,77 @@ test('legacy nested section requests now open the merged account panel', () => {
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});
test('current merged session group hides kick action and shows count', async () => {
const user = userEvent.setup();
renderAccountModal({
sessions: [
buildSession({
sessionId: 'usess_current',
sessionIds: ['usess_current', 'usess_rotated'],
sessionCount: 2,
isCurrent: true,
}),
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '踢下线' }),
).toBeNull();
});
test('remote merged session group can be revoked with loading state', async () => {
const user = userEvent.setup();
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
const remoteSession = buildSession({
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
});
renderAccountModal({
sessions: [remoteSession],
onRevokeSession,
revokingSessionIds: ['usess_remote'],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const revokeButton = within(accountDialog).getByRole('button', {
name: '处理中...',
}) as HTMLButtonElement;
expect(revokeButton.disabled).toBe(true);
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(onRevokeSession).not.toHaveBeenCalled();
});
test('remote session revoke passes the grouped session payload', async () => {
const user = userEvent.setup();
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
const remoteSession = buildSession({
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
});
renderAccountModal({
sessions: [remoteSession],
onRevokeSession,
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
'button',
{ name: '踢下线' },
),
);
expect(onRevokeSession).toHaveBeenCalledWith(remoteSession);
});

View File

@@ -40,7 +40,8 @@ type AccountModalProps = {
onRefreshSessions: () => Promise<void>;
onLogoutAll: () => Promise<void>;
onRefreshAuditLogs: () => Promise<void>;
onRevokeSession: (sessionId: string) => Promise<void>;
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds: string[];
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
onSendChangePhoneCode: (
phone: string,
@@ -298,6 +299,7 @@ export function AccountModal({
onLogoutAll,
onRefreshAuditLogs,
onRevokeSession,
revokingSessionIds,
changePhoneCaptchaChallenge,
onSendChangePhoneCode,
onChangePhone,
@@ -759,41 +761,55 @@ export function AccountModal({
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
sessions.map((session) => {
const isRevoking = revokingSessionIds.includes(
session.sessionId,
);
return (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<div className="flex shrink-0 items-center gap-2">
{session.sessionCount > 1 ? (
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{session.sessionCount}
</span>
) : null}
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
disabled={isRevoking}
onClick={() => {
void onRevokeSession(session);
}}
>
{isRevoking ? '处理中...' : '踢下线'}
</button>
) : null}
</div>
);
})
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">

View File

@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
@@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
getAuthAuditLogs: vi.fn(),
getAuthRiskBlocks: vi.fn(),
getAuthSessions: vi.fn(),
revokeAuthSessions: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
@@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthAuditLogs: authMocks.getAuthAuditLogs,
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: vi.fn(),
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
@@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({
logoutAuthUser: authMocks.logoutAuthUser,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
revokeAuthSessions: authMocks.revokeAuthSessions,
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
@@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({
}),
}));
vi.mock('./AccountModal', () => ({
AccountModal: () => null,
}));
vi.mock('./AccountModal', async () => {
const actual =
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
return actual;
});
vi.mock('./BindPhoneScreen', () => ({
BindPhoneScreen: () => <div></div>,
@@ -116,6 +123,10 @@ beforeEach(() => {
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.getAuthAuditLogs.mockResolvedValue([]);
authMocks.getAuthRiskBlocks.mockResolvedValue([]);
authMocks.getAuthSessions.mockResolvedValue([]);
authMocks.revokeAuthSessions.mockResolvedValue(undefined);
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
@@ -205,6 +216,21 @@ function LogoutStateProbe() {
);
}
function AccountPanelProbe() {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.openAccountModal();
}}
>
</button>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});
test('auth gate revokes merged session group and refreshes sessions', async () => {
const user = userEvent.setup();
const initialSessions: AuthSessionSummary[] = [
{
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
clientType: 'web_browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: 'Windows / Chrome',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
ipMasked: '203.0.*.*',
isCurrent: false,
createdAt: '2026-05-01T10:00:00.000Z',
lastSeenAt: '2026-05-01T10:30:00.000Z',
expiresAt: '2026-06-01T10:30:00.000Z',
},
];
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
authMocks.getAuthSessions
.mockResolvedValueOnce(initialSessions)
.mockResolvedValueOnce([]);
render(
<AuthGate>
<AccountPanelProbe />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '打开账号面板' }));
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
await waitFor(() => {
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
'usess_remote',
'usess_remote_rotated',
]);
expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2);
});
});
test('auth gate clears account state after password change', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
authMocks.getAuthSessions.mockResolvedValue([]);
authMocks.changePassword.mockResolvedValue(mockUser);
render(
<AuthGate>
<div>
<LogoutStateProbe />
<AccountPanelProbe />
</div>
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '打开账号面板' }));
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(
within(accountDialog).getByRole('button', { name: '修改密码' }),
);
const passwordDialog = await screen.findByRole('dialog', {
name: '修改登录密码',
});
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
await user.click(
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
);
await waitFor(() => {
expect(authMocks.changePassword).toHaveBeenCalledWith(
'oldpass1',
'newpass1',
);
expect(screen.getByText('当前用户:未登录')).toBeTruthy();
});
expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull();
});

View File

@@ -38,7 +38,7 @@ import {
logoutAuthUser,
redeemRegistrationInviteCode,
resetPassword,
revokeAuthSession,
revokeAuthSessions,
sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin,
@@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) {
useState<PlatformSettingsSection | null>(null);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
@@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) {
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
@@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) {
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
revokingSessionIds={revokingSessionIds}
isHydratingSettings={settings.isHydratingSettings}
isPersistingSettings={settings.isPersistingSettings}
settingsError={settings.settingsError}
@@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
onRevokeSession={async (session) => {
const sessionIds =
session.sessionIds.length > 0
? session.sessionIds
: [session.sessionId];
setRevokingSessionIds((current) =>
Array.from(new Set([...current, session.sessionId])),
);
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter(
(session) => session.sessionId !== sessionId,
),
);
await revokeAuthSessions(sessionIds);
setSessions(await getAuthSessions());
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
@@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) {
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
} finally {
setRevokingSessionIds((current) =>
current.filter((id) => id !== session.sessionId),
);
}
}}
onLogoutAll={logoutAllSessions}
@@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(nextUser);
}}
onChangePassword={async (currentPassword, newPassword) => {
const nextUser = await changePassword(
currentPassword,
newPassword,
);
setUser(nextUser);
await changePassword(currentPassword, newPassword);
clearLocalAuthenticatedState();
}}
/>
) : null}

View File

@@ -21,6 +21,7 @@ import {
authEntry,
bindWechatPhone,
changePhoneNumber,
changePassword,
consumeAuthCallbackResult,
getAuthAuditLogs,
getAuthLoginOptions,
@@ -33,6 +34,8 @@ import {
loginWithPhoneCode,
logoutAllAuthSessions,
redeemRegistrationInviteCode,
revokeAuthSession,
revokeAuthSessions,
sendPhoneLoginCode,
startWechatLogin,
updateAuthProfile,
@@ -154,6 +157,44 @@ describe('authService', () => {
);
});
it('change password clears local auth session after backend success', async () => {
window.localStorage.setItem(
'genarrative:access-token',
'jwt-before-password-change',
);
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_1',
publicUserCode: 'SY-00000001',
username: 'phone_00000001',
displayName: '旅人甲',
avatarUrl: null,
phoneNumberMasked: '138****8000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: '2026-05-01T00:00:00.000Z',
},
});
const user = await changePassword(' old-password ', ' new-password ');
expect(user.id).toBe('user_1');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/password/change',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
currentPassword: 'old-password',
newPassword: 'new-password',
}),
}),
'修改密码失败',
);
expect(getStoredAccessToken()).toBe('');
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
@@ -475,8 +516,15 @@ describe('authService', () => {
sessions: [
{
sessionId: 'usess_1',
sessionIds: ['usess_1', 'usess_2'],
sessionCount: 2,
clientType: 'browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: '网页端浏览器',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
ipMasked: '127.0.*.*',
isCurrent: true,
@@ -490,6 +538,46 @@ describe('authService', () => {
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']);
expect(sessions[0].sessionCount).toBe(2);
});
it('revokes a single auth session by backend route', async () => {
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
await revokeAuthSession('usess_1');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/sessions/usess_1/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('revokes grouped auth sessions once per unique session id', async () => {
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
1,
'/api/auth/sessions/usess_1/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
2,
'/api/auth/sessions/usess_2/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('loads recent auth audit logs', async () => {

View File

@@ -289,6 +289,7 @@ export async function changePassword(
'修改密码失败',
);
clearAuthSession();
return response.user;
}
@@ -441,6 +442,16 @@ export async function revokeAuthSession(sessionId: string) {
);
}
export async function revokeAuthSessions(sessionIds: string[]) {
const uniqueSessionIds = Array.from(
new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)),
);
await Promise.all(
uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)),
);
}
export async function getAuthAuditLogs() {
const response = await requestJson<AuthAuditLogsResponse>(
'/api/auth/audit-logs',