message / apps /web /src /api /native.ts
dot
Check browser CSRF cookies before login and recovery submissions
501eb9e
Raw History Blame Contribute Delete
9.09 kB
import type {
NativeArtifact, NativeOperation, NativeOperationKind, NativeOperationReceipt,
NativeProfile, NativeProfileSummary, NativeAuthStatus, NativeAuthSession, NativeClientKey, NativeAuditEvent,
} from '@typings/native';
export class NativeApiError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
this.name = 'NativeApiError';
}
}
/** HttpOnly 会话 cookie 仅由服务器设置;CSRF 只放在内存,不写入浏览器存储。 */
export class NativeApi {
private refreshing?: Promise<NativeAuthStatus>;
private loggingOut = false;
constructor(private csrfToken?: string) {}
private refreshSession() {
// 多个并行管理请求遇到过期 access cookie 时共用一次恢复,避免刷新凭据互相覆盖。
this.refreshing ??= this.authStatus().then(status => {
if (status.authenticated && status.csrf_token) this.csrfToken = status.csrf_token;
return status;
}).finally(() => { this.refreshing = undefined; });
return this.refreshing;
}
private async request<T>(path: string, options: RequestInit = {}, binary = false, secrets: string[] = [], retried = false): Promise<T> {
const attemptedCsrf = this.csrfToken;
const controller = new AbortController();
const abort = () => controller.abort();
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) controller.abort();
const timer = setTimeout(abort, 30000);
try {
const headers = new Headers(options.headers);
// 状态变更需要同时提交会话 cookie 和 CSRF,绝不发送管理员 bearer token。
if (options.method && !['GET', 'HEAD', 'OPTIONS'].includes(options.method.toUpperCase())) {
if (!this.csrfToken) throw new NativeApiError(403, '缺少会话校验信息,请刷新页面后重试');
headers.set('X-CSRF-Token', this.csrfToken);
}
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json');
const response = await fetch(`/api/native${path}`, {
...options, headers, signal: controller.signal, credentials: 'same-origin', cache: 'no-store',
});
// 此标记只由读取请求体之前的CSRF门禁生成;原请求尚未产生副作用,最多重试一次。
if (response.status === 403 && response.headers.get('X-Message-Error') === 'csrf' && !retried) {
const status = await this.authStatus(options.signal ?? undefined);
if (!status.csrf_token) throw new NativeApiError(403, '无法取得登录校验信息,请重新打开独立应用页面');
this.csrfToken = status.csrf_token;
await this.ensureCsrf(options.signal ?? undefined);
return this.request<T>(path, options, binary, secrets, true);
}
// 仅重试鉴权层拒绝的 401;网络失败和业务错误绝不自动重放写操作。
if (response.status === 401 && !retried && !this.loggingOut && !['/auth/status', '/auth/login', '/auth/recover', '/auth/password'].includes(path)) {
const status = this.csrfToken !== attemptedCsrf
? { authenticated: true, csrf_token: this.csrfToken } : await this.refreshSession();
if (status.authenticated && status.csrf_token && !controller.signal.aborted) {
this.csrfToken = status.csrf_token;
return this.request<T>(path, options, binary, secrets, true);
}
}
if (!response.ok) {
let detail = `请求失败(HTTP ${response.status})`;
try {
const body: unknown = await response.json();
if (body && typeof body === 'object' && 'detail' in body && typeof body.detail === 'string') detail = body.detail;
} catch { /* 错误页不一定是 JSON,保留状态码即可诊断。 */ }
// 即使错误响应意外回显密码或 CSRF,也不在界面中展示。
for (const secret of [this.csrfToken, attemptedCsrf, ...secrets]) {
if (secret) detail = detail.split(secret).join('[已隐藏凭据]');
}
throw new NativeApiError(response.status, detail);
}
if (response.status === 204) return undefined as T;
return (binary ? await response.blob() : await response.json()) as T;
} catch (error) {
if (error instanceof NativeApiError) throw error;
if (controller.signal.aborted) throw new NativeApiError(0, '请求已取消或超时;操作可能已被服务器接收,请刷新历史确认');
throw new NativeApiError(0, '网络请求失败,请检查连接并重试;操作结果以服务器记录为准');
} finally {
clearTimeout(timer);
options.signal?.removeEventListener('abort', abort);
}
}
async ensureCsrf(signal?: AbortSignal) {
const probe = () => this.request<{ ready: boolean }>('/auth/csrf', { signal, headers: { 'X-CSRF-Token': this.csrfToken ?? '' } });
try { return await probe(); }
catch (error) {
if (!(error instanceof NativeApiError) || error.status !== 409) throw error;
// 多窗口或并发状态响应可使页面CSRF过时,只重取校验,不重发邮件或密码。
const status = await this.authStatus(signal);
this.csrfToken = status.csrf_token;
return probe();
}
}
authStatus(signal?: AbortSignal) { return this.request<NativeAuthStatus>('/auth/status', { signal }); }
login(email: string, password: string, signal?: AbortSignal) {
return this.request<NativeAuthSession>('/auth/login', {
method: 'POST', body: JSON.stringify({ email, password }), signal,
}, false, [password]);
}
recover(email: string, signal?: AbortSignal) {
return this.request<{ message?: string }>('/auth/recover', {
method: 'POST', body: JSON.stringify({ email }), signal,
});
}
updatePassword(password: string, signal?: AbortSignal) {
return this.request<{ message?: string }>('/auth/password', {
method: 'POST', body: JSON.stringify({ password }), signal,
}, false, [password]);
}
async logout() {
// 已启动的刷新先结束,再撤销服务器会话,避免较晚的刷新响应覆盖退出 cookie。
this.loggingOut = true;
try { await this.refreshing; return await this.request<void>('/auth/logout', { method: 'POST' }); }
finally { this.loggingOut = false; }
}
keys(profile: string, signal?: AbortSignal) {
return this.request<{ items: NativeClientKey[] }>(`/profiles/${encodeURIComponent(profile)}/keys`, { signal });
}
createKey(profile: string, name: string, expiresInDays: number, signal?: AbortSignal) {
return this.request<NativeClientKey & { key: string }>(`/profiles/${encodeURIComponent(profile)}/keys`, {
method: 'POST', body: JSON.stringify({ name, expires_in_days: expiresInDays }), signal,
});
}
revokeKey(profile: string, keyId: string, signal?: AbortSignal) {
return this.request<unknown>(`/profiles/${encodeURIComponent(profile)}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', signal });
}
audit(signal?: AbortSignal) { return this.request<{ items: NativeAuditEvent[] }>('/auth/audit', { signal }); }
catalog(signal?: AbortSignal) { return this.request<{ items: NativeArtifact[] }>('/catalog', { signal }); }
profiles(signal?: AbortSignal) { return this.request<{ items: NativeProfileSummary[] }>('/profiles', { signal }); }
profile(id: string, signal?: AbortSignal) { return this.request<NativeProfile>(`/profiles/${encodeURIComponent(id)}`, { signal }); }
createProfile(id: string, signal?: AbortSignal) {
return this.request<NativeProfileSummary>('/profiles', { method: 'POST', body: JSON.stringify({ id }), signal });
}
upload(file: File, signal?: AbortSignal) {
const body = new FormData();
body.append('file', file);
return this.request<NativeArtifact>('/catalog', { method: 'POST', body, signal });
}
download(id: string, signal?: AbortSignal) {
return this.request<Blob>(`/catalog/${encodeURIComponent(id)}/download`, { signal }, true);
}
operate(id: string, kind: NativeOperationKind, body: Record<string, unknown> | undefined, key: string) {
return this.request<NativeOperationReceipt>(`/profiles/${encodeURIComponent(id)}/${kind}`, {
method: 'POST', body: body ? JSON.stringify(body) : undefined, headers: { 'Idempotency-Key': key },
});
}
operations(profile: string, signal?: AbortSignal) {
return this.request<{ items: NativeOperation[] }>(`/operations?profile_id=${encodeURIComponent(profile)}`, { signal });
}
operation(id: string, signal?: AbortSignal) {
return this.request<NativeOperation>(`/operations/${encodeURIComponent(id)}`, { signal });
}
cancel(id: string) { return this.request<NativeOperation>(`/operations/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); }
call(id: string, name: string, args: Record<string, unknown>, signal?: AbortSignal) {
return this.request<unknown>(`/profiles/${encodeURIComponent(id)}/call`, {
method: 'POST', body: JSON.stringify({ name, arguments: args }), signal,
});
}
}