feat: harden sessions and track downstream acknowledgements
This commit is contained in:
+95
-19
@@ -1,9 +1,22 @@
|
||||
import { clearSession, getSessionTenantId, readSession, type LoginSession } from './session';
|
||||
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session';
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
tenantId?: string;
|
||||
reauthenticationAttempted?: boolean;
|
||||
};
|
||||
|
||||
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
||||
|
||||
async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
const text = await response.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text) as ApiErrorBody;
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
@@ -27,19 +40,30 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
|
||||
await requestReauthentication();
|
||||
return request<T>(path, { ...options, reauthenticationAttempted: true });
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
@@ -49,19 +73,30 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const headers = new Headers(options.headers);
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
|
||||
await requestReauthentication();
|
||||
return requestBlob(path, { ...options, reauthenticationAttempted: true });
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
@@ -689,6 +724,8 @@ export type EnterpriseApplication = {
|
||||
queuePriority?: 'normal' | 'priority' | string | null;
|
||||
maxPhonesPerTask?: number | null;
|
||||
templateMismatchMode?: string | null;
|
||||
downstreamReceiptRetryEnabled?: boolean | null;
|
||||
downstreamUplinkRetryEnabled?: boolean | null;
|
||||
cmppAccount?: string | null;
|
||||
cmppEnterpriseCode?: string | null;
|
||||
interfaceEnabled?: boolean | null;
|
||||
@@ -775,7 +812,15 @@ export type DownstreamDeliveryRecord = {
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
retryCount: number;
|
||||
retryEnabled: boolean;
|
||||
nextRetryAt?: string | null;
|
||||
sentAt?: string | null;
|
||||
acknowledgedAt?: string | null;
|
||||
ackDeadlineAt?: string | null;
|
||||
ackResult?: number | null;
|
||||
ackSequenceId?: string | null;
|
||||
ackMessageId?: string | null;
|
||||
connectionId?: string | null;
|
||||
deliveredAt?: string | null;
|
||||
lastError?: string | null;
|
||||
createdAt: string;
|
||||
@@ -796,9 +841,13 @@ export type DownstreamDeliveryDashboard = {
|
||||
summary: {
|
||||
total: number;
|
||||
pending: number;
|
||||
awaitingAck: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
unconfirmed: number;
|
||||
rejected: number;
|
||||
stalledPending: number;
|
||||
stalledAck: number;
|
||||
recentFailed: number;
|
||||
alertCount: number;
|
||||
};
|
||||
@@ -806,8 +855,11 @@ export type DownstreamDeliveryDashboard = {
|
||||
deliveryType: string;
|
||||
total: number;
|
||||
pending: number;
|
||||
awaitingAck: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
unconfirmed: number;
|
||||
rejected: number;
|
||||
}>;
|
||||
retryBuckets: Array<{
|
||||
label: string;
|
||||
@@ -817,7 +869,10 @@ export type DownstreamDeliveryDashboard = {
|
||||
applicationId: string;
|
||||
name: string;
|
||||
pending: number;
|
||||
awaitingAck: number;
|
||||
failed: number;
|
||||
unconfirmed: number;
|
||||
rejected: number;
|
||||
delivered: number;
|
||||
alertCount: number;
|
||||
}>;
|
||||
@@ -881,6 +936,11 @@ export const adminApi = {
|
||||
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
touchSession: () => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/touch', { method: 'POST', body: '{}' }),
|
||||
lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }),
|
||||
unlockSession: (password: string) => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
reauthenticate: (password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
||||
logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }),
|
||||
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
||||
request<ManagedUser>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
@@ -912,9 +972,9 @@ export const adminApi = {
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
|
||||
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||
@@ -1103,13 +1163,21 @@ export const adminApi = {
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const error = await readErrorBody(response.clone());
|
||||
if (error.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: error.message });
|
||||
} else {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
@@ -1210,13 +1278,21 @@ export const clientApi = {
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const error = await readErrorBody(response.clone());
|
||||
if (error.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: error.message });
|
||||
} else {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user