feat: add native password and sms login

This commit is contained in:
2026-08-19 12:00:34 +08:00
parent abece81fdb
commit 5a048da8a6
9 changed files with 1539 additions and 859 deletions

View File

@@ -1,6 +1,4 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { createCipheriv } from 'node:crypto';
import { shell } from 'electron';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
@@ -27,22 +25,23 @@ import { logger } from '../../utils/logger';
import type { WorksSquareTokenPayload } from '../../services/works-square-session';
import { normalizeModuleAccess } from '../../../shared/module-access';
type AuthClientInput = {
authBase?: unknown;
clientId?: unknown;
clientSecret?: unknown;
};
type PasswordLoginInput = AuthClientInput & {
type PasswordLoginInput = {
username?: unknown;
password?: unknown;
};
type MobileLoginInput = {
phone?: unknown;
code?: unknown;
randomStr?: unknown;
scope?: unknown;
};
type MobileCodeInput = {
phone?: unknown;
imageRandomStr?: unknown;
imageCode?: unknown;
};
type LogoutInput = {
authBase?: unknown;
accessToken?: unknown;
};
@@ -58,20 +57,10 @@ type SessionRefreshInput = {
forceRefresh?: unknown;
};
type DesktopAuthStartPayload = {
request_id?: unknown;
device_secret?: unknown;
authorize_url?: unknown;
poll_interval_seconds?: unknown;
};
type DesktopAuthTokenPayload = {
status?: unknown;
token?: unknown;
};
const DESKTOP_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
const MAX_AUTH_ERROR_LENGTH = 180;
const MAX_CAPTCHA_IMAGE_BYTES = 1024 * 1024;
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()) {
@@ -80,10 +69,6 @@ function readRequiredString(value: unknown, field: string): string {
return value.trim();
}
function readOptionalString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function readOptionalTrimmedString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
@@ -107,8 +92,8 @@ function withoutRefreshToken(payload: unknown): unknown {
return publicPayload;
}
function normalizeAuthBase(value: unknown = NIANCODE_AUTH_CONFIG.gatewayAuthUrl): string {
const authBase = readOptionalString(value, NIANCODE_AUTH_CONFIG.gatewayAuthUrl).replace(/\/+$/, '');
function normalizeAuthBase(): string {
const authBase = NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '');
if (!/^https?:\/\//i.test(authBase)) {
throw new Error('authBase must start with http:// or https://');
}
@@ -127,34 +112,6 @@ function createWorksUrl(pathname: string): URL {
return new URL(`${normalizeWorksBase()}${pathname}`);
}
function createBasicAuthHeader(clientId: string, clientSecret: string): string {
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
function createTokenRequestBody(params: Record<string, string>): URLSearchParams {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
body.set(key, value);
}
return body;
}
function encryptPasswordForTokenEndpoint(password: string, encodeKey: string): string {
const key = Buffer.from(encodeKey, 'utf8');
if (![16, 24, 32].includes(key.length)) {
throw new Error('Auth password encode key must be 16, 24, or 32 bytes');
}
const cipher = createCipheriv(`aes-${key.length * 8}-cfb`, key, key);
return Buffer.concat([
cipher.update(password, 'utf8'),
cipher.final(),
]).toString('base64');
}
function shouldEncryptPasswordForClient(clientId: string): boolean {
return clientId !== 'customPC';
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
@@ -180,7 +137,7 @@ function getErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
for (const field of ['msg', 'message', 'error_description', 'error']) {
for (const field of ['detail', 'msg', 'message', 'error_description', 'error']) {
const value = record[field];
if (typeof value === 'string' && value.trim()) {
return compact(value);
@@ -193,113 +150,101 @@ function getErrorMessage(payload: unknown, fallback: string): string {
return fallback;
}
function readRequiredPayloadString(
payload: Record<string, unknown>,
field: keyof DesktopAuthStartPayload,
): string {
return readRequiredString(payload[field], field);
}
function readPollIntervalMs(value: unknown): number {
const seconds = typeof value === 'number'
? value
: (typeof value === 'string' ? Number(value) : 2);
if (!Number.isFinite(seconds) || seconds < 0) return 2_000;
return Math.floor(seconds * 1000);
}
function delay(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function requestToken(
authBase: string,
clientId: string,
clientSecret: string,
body: URLSearchParams,
): Promise<{ ok: boolean; status: number; payload: unknown }> {
const response = await proxyAwareFetch(`${authBase}/oauth2/token`, {
method: 'POST',
headers: {
Authorization: createBasicAuthHeader(clientId, clientSecret),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
return {
ok: response.ok,
status: response.status,
payload: await readResponsePayload(response),
};
}
async function pollDesktopAuthToken(
requestId: string,
deviceSecret: string,
pollIntervalMs: number,
): Promise<unknown> {
const startedAt = Date.now();
while (Date.now() - startedAt < DESKTOP_AUTH_TIMEOUT_MS) {
const tokenUrl = createWorksUrl('/api/auth/desktop/token');
tokenUrl.searchParams.set('request_id', requestId);
tokenUrl.searchParams.set('device_secret', deviceSecret);
const response = await proxyAwareFetch(tokenUrl.toString(), { method: 'GET' });
const payload = await readResponsePayload(response) as DesktopAuthTokenPayload;
if (!response.ok) {
throw new Error(getErrorMessage(payload, '登录授权失败,请稍后重试。'));
}
if (payload.status === 'approved' && payload.token) {
return payload.token;
}
await delay(pollIntervalMs);
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');
}
throw new Error('登录超时,请重新尝试。');
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;
}
async function handleBrowserAuthorization(
res: ServerResponse,
ctx: HostApiContext,
): Promise<void> {
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
const response = await proxyAwareFetch(createWorksUrl('/api/auth/desktop/start').toString(), {
method: 'POST',
});
const payload = await readResponsePayload(response);
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;
}
if (!response.ok) {
sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, {
success: false,
error: getErrorMessage(payload, '登录服务暂时不可用,请稍后重试。'),
});
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,
): Promise<void> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
sendJson(res, 502, { success: false, error: '登录服务返回了无效响应,请稍后重试。' });
return;
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Desktop authorization start returned an invalid payload');
try {
const session = await commitWorksSquareSessionFromTokenPayload(
payload as WorksSquareTokenPayload,
);
sendJson(res, 200, {
success: true,
token: withoutRefreshToken(payload),
session,
});
} catch {
sendJson(res, 503, { success: false, error: '登录状态暂时无法保存,请稍后重试。' });
}
const record = payload as Record<string, unknown>;
const requestId = readRequiredPayloadString(record, 'request_id');
const deviceSecret = readRequiredPayloadString(record, 'device_secret');
const authorizeUrl = readRequiredPayloadString(record, 'authorize_url');
const pollIntervalMs = readPollIntervalMs(record.poll_interval_seconds);
await shell.openExternal(authorizeUrl);
const token = await pollDesktopAuthToken(requestId, deviceSecret, pollIntervalMs);
let session = null;
if (token && typeof token === 'object' && !Array.isArray(token)) {
session = await commitWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload);
}
sendJson(res, 200, { success: true, token: withoutRefreshToken(token), session });
}
async function handlePasswordLogin(
@@ -308,60 +253,204 @@ async function handlePasswordLogin(
ctx: HostApiContext,
): Promise<void> {
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
const body = await parseJsonBody<PasswordLoginInput>(req);
const authBase = normalizeAuthBase(body.authBase);
const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId);
const clientSecret = readOptionalString(body.clientSecret, NIANCODE_AUTH_CONFIG.clientSecret);
const body = readExactJsonObject(
await parseJsonBody<PasswordLoginInput>(req),
['username', 'password'],
);
const username = readRequiredString(body.username, 'username');
const password = readRequiredString(body.password, 'password');
const scope = readOptionalString(body.scope, NIANCODE_AUTH_CONFIG.scope);
const code = readOptionalTrimmedString(body.code);
const randomStr = readOptionalTrimmedString(body.randomStr);
const tokenPassword = shouldEncryptPasswordForClient(clientId)
? encryptPasswordForTokenEndpoint(password, NIANCODE_AUTH_CONFIG.passwordEncodeKey)
: password;
const tokenRequestParams: Record<string, string> = {
grant_type: 'password',
username,
password: tokenPassword,
scope,
};
const result = await fetchWorks('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (code && randomStr) {
tokenRequestParams.code = code;
tokenRequestParams.randomStr = randomStr;
if (!result || !result.response.ok || isFailureEnvelope(result.payload)) {
sendUpstreamFailure(
res,
result?.response ?? null,
result?.payload,
'登录服务暂时不可用,请稍后重试。',
);
return;
}
await commitLoginPayload(res, result.payload);
}
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');
}
const tokenResult = await requestToken(
authBase,
clientId,
clientSecret,
createTokenRequestBody(tokenRequestParams),
);
if (!tokenResult.ok) {
sendJson(res, tokenResult.status === 401 ? 401 : 502, {
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: getErrorMessage(tokenResult.payload, `Login failed (${tokenResult.status})`),
error: '图形验证码服务暂时不可用,请稍后重试。',
});
return;
}
let session = null;
if (
tokenResult.payload
&& typeof tokenResult.payload === 'object'
&& !Array.isArray(tokenResult.payload)
) {
session = await commitWorksSquareSessionFromTokenPayload(
tokenResult.payload as WorksSquareTokenPayload,
);
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,
token: withoutRefreshToken(tokenResult.payload),
session,
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),
},
});
}
@@ -559,8 +648,11 @@ async function handleLogout(
res: ServerResponse,
ctx: HostApiContext,
): Promise<void> {
const body = await parseJsonBody<LogoutInput>(req);
const authBase = normalizeAuthBase(body.authBase);
const body = readExactJsonObject(
await parseJsonBody<LogoutInput>(req),
['accessToken'],
);
const authBase = normalizeAuthBase();
const rendererAccessToken = readOptionalTrimmedString(body.accessToken);
const accessToken = getWorksSquareSessionSnapshot()?.accessToken
?? readRequiredString(rendererAccessToken, 'accessToken');
@@ -630,13 +722,28 @@ export async function handleAuthRoutes(
}
try {
if (url.pathname === '/api/auth/browser/start' && req.method === 'POST') {
await handleBrowserAuthorization(res, ctx);
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
await handlePasswordLogin(req, res, ctx);
return true;
}
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
await handlePasswordLogin(req, res, ctx);
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;
}