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

@@ -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',