859 lines
27 KiB
TypeScript
859 lines
27 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import type { HostApiContext } from '../context';
|
|
import { parseJsonBody, sendJson } from '../route-utils';
|
|
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
|
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
|
import {
|
|
clearWorksSquareSession,
|
|
commitWorksSquareSession,
|
|
commitWorksSquareSessionFromTokenPayload,
|
|
discardUnrestorableWorksSquareSession,
|
|
flushWorksSquareSessionPersistence,
|
|
getValidWorksSquareAccessToken,
|
|
getWorksSquareSessionRestoreStatus,
|
|
getWorksSquareSessionSnapshot,
|
|
markWorksSquareSessionActive,
|
|
retryWorksSquareSessionRestore,
|
|
} from '../../services/works-square-session';
|
|
import {
|
|
clearManagedWorksSquareRuntime,
|
|
clearManagedWorksSquareRuntimeBestEffort,
|
|
ensureManagedWorksSquareRuntimeClean,
|
|
} from '../../services/works-square-runtime';
|
|
import { logger } from '../../utils/logger';
|
|
import type { WorksSquareTokenPayload } from '../../services/works-square-session';
|
|
import { normalizeModuleAccess } from '../../../shared/module-access';
|
|
import {
|
|
getRememberedPasswordState,
|
|
updateRememberedPassword,
|
|
} from '../../services/remembered-password';
|
|
|
|
type PasswordLoginInput = {
|
|
username?: unknown;
|
|
password?: unknown;
|
|
rememberPassword?: unknown;
|
|
};
|
|
|
|
type MobileLoginInput = {
|
|
phone?: unknown;
|
|
code?: unknown;
|
|
};
|
|
|
|
type MobileCodeInput = {
|
|
phone?: unknown;
|
|
imageRandomStr?: unknown;
|
|
imageCode?: unknown;
|
|
};
|
|
|
|
type LogoutInput = {
|
|
accessToken?: unknown;
|
|
};
|
|
|
|
type SessionSyncInput = {
|
|
accessToken?: unknown;
|
|
refreshToken?: unknown;
|
|
tokenType?: unknown;
|
|
expiresAt?: unknown;
|
|
lastActiveAt?: unknown;
|
|
};
|
|
|
|
type SessionRefreshInput = {
|
|
forceRefresh?: unknown;
|
|
};
|
|
|
|
const MAX_AUTH_ERROR_LENGTH = 180;
|
|
const MAX_CAPTCHA_IMAGE_BYTES = 1024 * 1024;
|
|
const MAX_PUBLIC_USERNAME_LENGTH = 256;
|
|
const MAX_PUBLIC_ID_LENGTH = 128;
|
|
const MAX_PUBLIC_AUTHORITIES = 100;
|
|
const MAX_PUBLIC_AUTHORITY_LENGTH = 128;
|
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
|
|
function readRequiredString(value: unknown, field: string): string {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
throw new Error(`Missing ${field}`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function readOptionalTrimmedString(value: unknown): string | null {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function readOptionalNumber(value: unknown): number | null {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readOptionalBoolean(value: unknown, fallback: boolean): boolean {
|
|
return typeof value === 'boolean' ? value : fallback;
|
|
}
|
|
|
|
function readBoundedString(value: unknown, maxLength: number): string | null {
|
|
const normalized = readOptionalTrimmedString(value);
|
|
return normalized && normalized.length <= maxLength ? normalized : null;
|
|
}
|
|
|
|
function readPublicStringId(...values: unknown[]): string | null {
|
|
for (const value of values) {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
const normalized = readBoundedString(value, MAX_PUBLIC_ID_LENGTH);
|
|
if (normalized) return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readPublicStringOrNumberId(...values: unknown[]): string | number | null {
|
|
for (const value of values) {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
const normalized = readBoundedString(value, MAX_PUBLIC_ID_LENGTH);
|
|
if (normalized) return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readPublicAuthorities(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map((authority) => readBoundedString(authority, MAX_PUBLIC_AUTHORITY_LENGTH))
|
|
.filter((authority): authority is string => authority !== null)
|
|
.slice(0, MAX_PUBLIC_AUTHORITIES);
|
|
}
|
|
|
|
function projectCurrentUser(profile: Record<string, unknown>): {
|
|
username: string;
|
|
userId: string | null;
|
|
tenantId: string | number | null;
|
|
deptId: string | number | null;
|
|
authorities: string[];
|
|
} | null {
|
|
const username = readBoundedString(profile.username, MAX_PUBLIC_USERNAME_LENGTH)
|
|
?? readBoundedString(profile.user_name, MAX_PUBLIC_USERNAME_LENGTH);
|
|
if (!username) return null;
|
|
|
|
return {
|
|
username,
|
|
userId: readPublicStringId(profile.user_id, profile.userId, profile.id),
|
|
tenantId: readPublicStringOrNumberId(profile.tenant_id, profile.tenantId),
|
|
deptId: readPublicStringOrNumberId(profile.dept_id, profile.deptId),
|
|
authorities: readPublicAuthorities(profile.authorities),
|
|
};
|
|
}
|
|
|
|
function withoutRefreshToken(payload: unknown): unknown {
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
|
const { refresh_token: _refreshToken, ...publicPayload } = payload as Record<string, unknown>;
|
|
return publicPayload;
|
|
}
|
|
|
|
function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string {
|
|
const apiBase = value.replace(/\/+$/, '');
|
|
if (!/^https?:\/\//i.test(apiBase)) {
|
|
throw new Error('Works Square API base URL must start with http:// or https://');
|
|
}
|
|
return apiBase;
|
|
}
|
|
|
|
function createWorksUrl(pathname: string): URL {
|
|
return new URL(`${normalizeWorksBase()}${pathname}`);
|
|
}
|
|
|
|
async function readResponsePayload(response: Response): Promise<unknown> {
|
|
const text = await response.text();
|
|
if (!text.trim()) return null;
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function getErrorMessage(payload: unknown, fallback: string): string {
|
|
const compact = (value: string): string => {
|
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
if (
|
|
!normalized
|
|
|| normalized.length > MAX_AUTH_ERROR_LENGTH
|
|
|| /<!doctype\b|<html\b|<head\b|<body\b/i.test(normalized)
|
|
) {
|
|
return fallback;
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
if (payload && typeof payload === 'object') {
|
|
const record = payload as Record<string, unknown>;
|
|
for (const field of ['detail', 'msg', 'message', 'error_description', 'error']) {
|
|
const value = record[field];
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return compact(value);
|
|
}
|
|
}
|
|
}
|
|
if (typeof payload === 'string' && payload.trim()) {
|
|
return compact(payload);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function readExactJsonObject(
|
|
value: unknown,
|
|
allowedFields: readonly string[],
|
|
): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('Request body must be a JSON object');
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const unexpectedField = Object.keys(record).find((field) => !allowedFields.includes(field));
|
|
if (unexpectedField) {
|
|
throw new Error(`Unexpected field: ${unexpectedField}`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function isFailureEnvelope(payload: unknown): boolean {
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
const record = payload as Record<string, unknown>;
|
|
if (record.success === false || record.data === false) return true;
|
|
if (typeof record.code === 'number') return record.code !== 0;
|
|
if (typeof record.code === 'string' && record.code.trim()) return record.code.trim() !== '0';
|
|
return false;
|
|
}
|
|
|
|
async function fetchWorks(
|
|
pathname: string,
|
|
init: RequestInit,
|
|
): Promise<{ response: Response; payload: unknown } | null> {
|
|
try {
|
|
const response = await proxyAwareFetch(createWorksUrl(pathname).toString(), init);
|
|
return { response, payload: await readResponsePayload(response) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function readBoundedResponseBytes(response: Response, maxBytes: number): Promise<Buffer | null> {
|
|
if (!response.body) return Buffer.alloc(0);
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let totalBytes = 0;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > maxBytes) {
|
|
await reader.cancel();
|
|
return null;
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
return Buffer.concat(chunks, totalBytes);
|
|
}
|
|
|
|
function sendUpstreamFailure(
|
|
res: ServerResponse,
|
|
response: Response | null,
|
|
payload: unknown,
|
|
serviceFailureMessage: string,
|
|
): void {
|
|
const isClientError = response !== null && (
|
|
response.ok || (response.status >= 400 && response.status < 500)
|
|
);
|
|
sendJson(res, response?.ok ? 400 : (isClientError ? (response?.status ?? 400) : 502), {
|
|
success: false,
|
|
error: isClientError
|
|
? getErrorMessage(payload, '请求未通过,请检查后重试。')
|
|
: serviceFailureMessage,
|
|
});
|
|
}
|
|
|
|
async function commitLoginPayload(
|
|
res: ServerResponse,
|
|
payload: unknown,
|
|
onCommitted?: () => Promise<void>,
|
|
): Promise<void> {
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
sendJson(res, 502, { success: false, error: '登录服务返回了无效响应,请稍后重试。' });
|
|
return;
|
|
}
|
|
try {
|
|
const session = await commitWorksSquareSessionFromTokenPayload(
|
|
payload as WorksSquareTokenPayload,
|
|
);
|
|
await onCommitted?.();
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
token: withoutRefreshToken(payload),
|
|
session,
|
|
});
|
|
} catch {
|
|
sendJson(res, 503, { success: false, error: '登录状态暂时无法保存,请稍后重试。' });
|
|
}
|
|
}
|
|
|
|
async function handlePasswordLogin(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
|
|
const body = readExactJsonObject(
|
|
await parseJsonBody<PasswordLoginInput>(req),
|
|
['username', 'password', 'rememberPassword'],
|
|
);
|
|
const username = readRequiredString(body.username, 'username');
|
|
const password = readRequiredString(body.password, 'password');
|
|
const rememberPassword = readOptionalBoolean(body.rememberPassword, false);
|
|
const result = await fetchWorks('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
|
|
if (!result || !result.response.ok || isFailureEnvelope(result.payload)) {
|
|
sendUpstreamFailure(
|
|
res,
|
|
result?.response ?? null,
|
|
result?.payload,
|
|
'登录服务暂时不可用,请稍后重试。',
|
|
);
|
|
return;
|
|
}
|
|
await commitLoginPayload(res, result.payload, async () => {
|
|
await updateRememberedPassword(
|
|
rememberPassword ? { username, password } : null,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function handleRememberedPassword(res: ServerResponse): Promise<void> {
|
|
const state = await getRememberedPasswordState();
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
sendJson(res, 200, { success: true, ...state });
|
|
}
|
|
|
|
async function handleMobileLogin(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
|
|
const body = readExactJsonObject(
|
|
await parseJsonBody<MobileLoginInput>(req),
|
|
['phone', 'code'],
|
|
);
|
|
const phone = readRequiredString(body.phone, 'phone');
|
|
const code = readRequiredString(body.code, 'code');
|
|
const result = await fetchWorks('/api/auth/mobile-login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ phone, code }),
|
|
});
|
|
|
|
if (!result || !result.response.ok || isFailureEnvelope(result.payload)) {
|
|
sendUpstreamFailure(
|
|
res,
|
|
result?.response ?? null,
|
|
result?.payload,
|
|
'登录服务暂时不可用,请稍后重试。',
|
|
);
|
|
return;
|
|
}
|
|
await commitLoginPayload(res, result.payload);
|
|
}
|
|
|
|
async function handleMobileCode(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const body = readExactJsonObject(
|
|
await parseJsonBody<MobileCodeInput>(req),
|
|
['phone', 'imageRandomStr', 'imageCode'],
|
|
);
|
|
const phone = readRequiredString(body.phone, 'phone');
|
|
const imageRandomStr = readRequiredString(body.imageRandomStr, 'imageRandomStr');
|
|
const imageCode = readRequiredString(body.imageCode, 'imageCode');
|
|
if (!UUID_PATTERN.test(imageRandomStr)) {
|
|
throw new Error('imageRandomStr must be a UUID');
|
|
}
|
|
const result = await fetchWorks('/api/auth/mobile-code', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ phone, imageRandomStr, imageCode }),
|
|
});
|
|
|
|
if (
|
|
!result
|
|
|| !result.response.ok
|
|
|| !result.payload
|
|
|| typeof result.payload !== 'object'
|
|
|| Array.isArray(result.payload)
|
|
|| isFailureEnvelope(result.payload)
|
|
) {
|
|
sendUpstreamFailure(
|
|
res,
|
|
result?.response ?? null,
|
|
result?.payload,
|
|
'短信验证码服务暂时不可用,请稍后重试。',
|
|
);
|
|
return;
|
|
}
|
|
sendJson(res, 200, { success: true });
|
|
}
|
|
|
|
async function handleMobileImageCode(url: URL, res: ServerResponse): Promise<void> {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
const randomStr = readRequiredString(url.searchParams.get('randomStr'), 'randomStr');
|
|
if (!UUID_PATTERN.test(randomStr)) {
|
|
throw new Error('randomStr must be a UUID');
|
|
}
|
|
if (
|
|
url.searchParams.getAll('randomStr').length !== 1
|
|
|| [...url.searchParams.keys()].some((key) => key !== 'randomStr')
|
|
) {
|
|
throw new Error('Unexpected query parameter');
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
const upstreamUrl = createWorksUrl('/api/auth/mobile-image-code');
|
|
upstreamUrl.searchParams.set('randomStr', randomStr);
|
|
response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' });
|
|
} catch {
|
|
sendJson(res, 502, {
|
|
success: false,
|
|
error: '图形验证码服务暂时不可用,请稍后重试。',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const payload = await readResponsePayload(response);
|
|
sendUpstreamFailure(res, response, payload, '图形验证码服务暂时不可用,请稍后重试。');
|
|
return;
|
|
}
|
|
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
const contentLength = Number(response.headers.get('content-length'));
|
|
if (
|
|
contentType !== 'image/png'
|
|
|| (Number.isFinite(contentLength) && contentLength > MAX_CAPTCHA_IMAGE_BYTES)
|
|
) {
|
|
sendJson(res, 502, {
|
|
success: false,
|
|
error: '图形验证码服务返回了无效图片,请稍后重试。',
|
|
});
|
|
return;
|
|
}
|
|
const bytes = await readBoundedResponseBytes(response, MAX_CAPTCHA_IMAGE_BYTES);
|
|
if (
|
|
!bytes
|
|
|| bytes.length < PNG_MAGIC.length
|
|
|| !bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)
|
|
) {
|
|
sendJson(res, 502, {
|
|
success: false,
|
|
error: '图形验证码服务返回了无效图片,请稍后重试。',
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
image: {
|
|
mimeType: 'image/png',
|
|
dataBase64: bytes.toString('base64'),
|
|
},
|
|
});
|
|
}
|
|
|
|
function projectPublicUrl(value: unknown): string | null {
|
|
if (typeof value !== 'string' || !value.trim()) return null;
|
|
try {
|
|
const worksBase = new URL(`${normalizeWorksBase()}/`);
|
|
const projected = new URL(value.trim(), worksBase);
|
|
if (projected.username || projected.password) return null;
|
|
if (projected.origin !== worksBase.origin && projected.protocol !== 'https:') return null;
|
|
if (projected.origin === worksBase.origin && !['http:', 'https:'].includes(projected.protocol)) {
|
|
return null;
|
|
}
|
|
return projected.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function handlePublicConfig(res: ServerResponse): Promise<void> {
|
|
const result = await fetchWorks('/api/app-config', { method: 'GET' });
|
|
if (!result || !result.response.ok) {
|
|
sendUpstreamFailure(
|
|
res,
|
|
result?.response ?? null,
|
|
result?.payload,
|
|
'登录配置服务暂时不可用,请稍后重试。',
|
|
);
|
|
return;
|
|
}
|
|
const config = result.payload && typeof result.payload === 'object' && !Array.isArray(result.payload)
|
|
? result.payload as Record<string, unknown>
|
|
: {};
|
|
const legal = config.legal && typeof config.legal === 'object' && !Array.isArray(config.legal)
|
|
? config.legal as Record<string, unknown>
|
|
: {};
|
|
const auth = config.auth && typeof config.auth === 'object' && !Array.isArray(config.auth)
|
|
? config.auth as Record<string, unknown>
|
|
: {};
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
links: {
|
|
termsUrl: projectPublicUrl(legal.terms_url),
|
|
privacyUrl: projectPublicUrl(legal.privacy_url),
|
|
forgotPasswordUrl: projectPublicUrl(auth.forgot_password_url),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleSessionSync(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
if (!await ensureWorksSquareSessionRestored()) {
|
|
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
|
return;
|
|
}
|
|
try {
|
|
await flushWorksSquareSessionPersistence();
|
|
} catch (error) {
|
|
logger.warn('[auth] Session persistence is temporarily unavailable during sync', error);
|
|
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
|
|
return;
|
|
}
|
|
const body = await parseJsonBody<SessionSyncInput>(req);
|
|
let session = getWorksSquareSessionSnapshot();
|
|
const legacyAccessToken = readOptionalTrimmedString(body.accessToken);
|
|
if (!session && legacyAccessToken) {
|
|
try {
|
|
await ensureManagedWorksSquareRuntimeClean(ctx);
|
|
session = await commitWorksSquareSession({
|
|
accessToken: legacyAccessToken,
|
|
refreshToken: readOptionalTrimmedString(body.refreshToken),
|
|
tokenType: readOptionalTrimmedString(body.tokenType),
|
|
expiresAt: readOptionalNumber(body.expiresAt),
|
|
lastActiveAt: readOptionalNumber(body.lastActiveAt),
|
|
});
|
|
} catch (error) {
|
|
logger.warn('[auth] Failed to persist a migrated Renderer session', error);
|
|
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!session) {
|
|
sendJson(res, 200, { success: true, session: null });
|
|
return;
|
|
}
|
|
sendJson(res, 200, { success: true, session });
|
|
}
|
|
|
|
async function handleSessionRefresh(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
if (!await ensureWorksSquareSessionRestored()) {
|
|
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
|
return;
|
|
}
|
|
const body = await parseJsonBody<SessionRefreshInput>(req);
|
|
try {
|
|
const accessToken = await getValidWorksSquareAccessToken({
|
|
forceRefresh: readOptionalBoolean(body.forceRefresh, true),
|
|
});
|
|
const session = getWorksSquareSessionSnapshot();
|
|
if (accessToken && session) {
|
|
sendJson(res, 200, { success: true, session });
|
|
return;
|
|
}
|
|
if (!session) {
|
|
await clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal session refresh');
|
|
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
|
return;
|
|
}
|
|
sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' });
|
|
} catch (error) {
|
|
logger.warn('[auth] Failed to refresh the managed Works Square session', error);
|
|
sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' });
|
|
}
|
|
}
|
|
|
|
async function handleSessionActivity(
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
if (!await ensureWorksSquareSessionRestored()) {
|
|
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
|
return;
|
|
}
|
|
let session;
|
|
try {
|
|
session = await markWorksSquareSessionActive();
|
|
} catch (error) {
|
|
logger.warn('[auth] Failed to persist Works Square session activity', error);
|
|
sendJson(res, 503, { success: false, error: '登录活动暂时无法保存,请稍后重试。' });
|
|
return;
|
|
}
|
|
if (!session) {
|
|
await clearManagedWorksSquareRuntimeBestEffort(ctx, 'idle session activity');
|
|
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
|
return;
|
|
}
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
session,
|
|
});
|
|
}
|
|
|
|
async function handleCurrentUser(res: ServerResponse, ctx: HostApiContext): Promise<void> {
|
|
const accessToken = await getValidWorksSquareAccessToken({ forceRefresh: false });
|
|
if (!accessToken) {
|
|
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
|
return;
|
|
}
|
|
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/auth/me').toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
const payload = await readResponsePayload(response);
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearWorksSquareSession();
|
|
await Promise.allSettled([
|
|
flushWorksSquareSessionPersistence(),
|
|
clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal current-user lookup'),
|
|
]);
|
|
}
|
|
sendJson(res, response.status === 401 ? 401 : 502, {
|
|
success: false,
|
|
error: response.status === 401
|
|
? '登录已过期,请重新授权。'
|
|
: '暂时无法读取模块权限,请稍后重试。',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const profile = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
? payload as Record<string, unknown>
|
|
: {};
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
user: projectCurrentUser(profile),
|
|
moduleAccess: normalizeModuleAccess(profile.module_access),
|
|
});
|
|
}
|
|
|
|
async function ensureWorksSquareSessionRestored(): Promise<boolean> {
|
|
if (getWorksSquareSessionRestoreStatus() === 'unavailable') {
|
|
await retryWorksSquareSessionRestore();
|
|
}
|
|
return getWorksSquareSessionRestoreStatus() === 'ready';
|
|
}
|
|
|
|
async function ensureRuntimeReadyForLogin(
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
let discardedUnrestorableSession = false;
|
|
if (!await ensureWorksSquareSessionRestored()) {
|
|
if (!await discardUnrestorableWorksSquareSession()) {
|
|
sendJson(res, 503, {
|
|
success: false,
|
|
error: '登录状态暂时无法恢复,请稍后重试。',
|
|
});
|
|
return false;
|
|
}
|
|
discardedUnrestorableSession = true;
|
|
}
|
|
try {
|
|
if (discardedUnrestorableSession) {
|
|
await clearManagedWorksSquareRuntime(ctx, undefined, true);
|
|
}
|
|
await ensureManagedWorksSquareRuntimeClean(ctx);
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('[auth] Failed to clean the previous runtime before login', error);
|
|
sendJson(res, 503, {
|
|
success: false,
|
|
error: '上次登录的本地运行环境尚未清理完成,请稍后重试。',
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function handleSessionClear(res: ServerResponse, ctx: HostApiContext): Promise<void> {
|
|
const accessToken = getWorksSquareSessionSnapshot()?.accessToken;
|
|
clearWorksSquareSession();
|
|
await Promise.all([
|
|
flushWorksSquareSessionPersistence(),
|
|
clearManagedWorksSquareRuntime(ctx, accessToken),
|
|
]);
|
|
sendJson(res, 200, { success: true });
|
|
}
|
|
|
|
async function handleLogout(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
const body = readExactJsonObject(
|
|
await parseJsonBody<LogoutInput>(req),
|
|
['accessToken'],
|
|
);
|
|
const rendererAccessToken = readOptionalTrimmedString(body.accessToken);
|
|
const accessToken = getWorksSquareSessionSnapshot()?.accessToken
|
|
?? readRequiredString(rendererAccessToken, 'accessToken');
|
|
|
|
let cleanupError: Error | null = null;
|
|
clearWorksSquareSession();
|
|
try {
|
|
await clearManagedWorksSquareRuntime(ctx, accessToken);
|
|
} catch (error) {
|
|
cleanupError = error instanceof Error ? error : new Error(String(error));
|
|
logger.error('[auth] Failed to clear managed Works Square runtime state during logout', cleanupError);
|
|
}
|
|
try {
|
|
await flushWorksSquareSessionPersistence();
|
|
} catch (error) {
|
|
cleanupError = error instanceof Error ? error : new Error(String(error));
|
|
logger.error('[auth] Failed to persist the local session clear during logout', cleanupError);
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await proxyAwareFetch(createWorksUrl('/api/auth/logout').toString(), {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (cleanupError) {
|
|
sendJson(res, 500, {
|
|
success: false,
|
|
error: 'Failed to clear local AI runtime state',
|
|
});
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (cleanupError) {
|
|
sendJson(res, 500, {
|
|
success: false,
|
|
error: 'Failed to clear local AI runtime state',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const payload = await readResponsePayload(response);
|
|
sendJson(res, response.status === 401 ? 401 : 502, {
|
|
success: false,
|
|
error: getErrorMessage(payload, `Logout failed (${response.status})`),
|
|
});
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 200, { success: true });
|
|
}
|
|
|
|
export async function handleAuthRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (!url.pathname.startsWith('/api/auth')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
|
await handlePasswordLogin(req, res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/remembered-password' && req.method === 'GET') {
|
|
await handleRememberedPassword(res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/mobile-login' && req.method === 'POST') {
|
|
await handleMobileLogin(req, res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/mobile-code' && req.method === 'POST') {
|
|
await handleMobileCode(req, res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/mobile-image-code' && req.method === 'GET') {
|
|
await handleMobileImageCode(url, res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/public-config' && req.method === 'GET') {
|
|
await handlePublicConfig(res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/session/sync' && req.method === 'POST') {
|
|
await handleSessionSync(req, res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/session/refresh' && req.method === 'POST') {
|
|
await handleSessionRefresh(req, res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/session/activity' && req.method === 'POST') {
|
|
await handleSessionActivity(res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/me' && req.method === 'GET') {
|
|
await handleCurrentUser(res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/session/clear' && req.method === 'POST') {
|
|
await handleSessionClear(res, ctx);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
|
|
await handleLogout(req, res, ctx);
|
|
return true;
|
|
}
|
|
|
|
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` });
|
|
return true;
|
|
} catch (error) {
|
|
sendJson(res, 400, {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return true;
|
|
}
|
|
}
|