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,5 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'http';
import { shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleAuthRoutes } from '@electron/api/routes/auth';
import {
@@ -173,7 +172,7 @@ describe('auth host api routes', () => {
expect(getWorksSquareSessionSnapshot()).toBeNull();
});
it('exchanges username and AES-encrypted password through the app SSO token endpoint', async () => {
it('proxies password login through Works and commits a redacted Main session', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'access-token',
@@ -182,7 +181,6 @@ describe('auth host api routes', () => {
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
client_id: 'app',
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
@@ -192,9 +190,6 @@ describe('auth host api routes', () => {
createRequest('POST', {
username: 'zhangsan',
password: 'passw0rd',
code: 'a7k9',
randomStr: '333e6825-760c-4c1a-8b56-eb9539b43dbd',
scope: 'server',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
@@ -211,7 +206,6 @@ describe('auth host api routes', () => {
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
client_id: 'app',
},
session: {
accessToken: 'access-token',
@@ -224,16 +218,19 @@ describe('auth host api routes', () => {
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://biz.nianxx.cn/auth/oauth2/token');
expect(url).toBe('https://square.nianxx.cn/api/auth/login');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({
Authorization: `Basic ${Buffer.from('app:app').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
expect(init.headers).toEqual({ 'Content-Type': 'application/json' });
expect(JSON.parse(String(init.body))).toEqual({
username: 'zhangsan',
password: 'passw0rd',
});
expect(String(init.body)).toBe(
'grant_type=password&username=zhangsan&password=ILsdQ7Wp2P8%3D&scope=server&code=a7k9&randomStr=333e6825-760c-4c1a-8b56-eb9539b43dbd',
);
expect(String(init.body)).not.toContain('passw0rd');
expect(JSON.stringify(response.json())).not.toContain('refresh-token');
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'access-token',
canRefresh: true,
});
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
});
it('allows explicit reauthorization to replace an unreadable persisted session', async () => {
@@ -297,57 +294,8 @@ describe('auth host api routes', () => {
expect(JSON.stringify(response.json())).not.toContain('app:app');
});
it('replaces an upstream HTML gateway error with a concise browser-login message', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
'<html><head><title>502 Bad Gateway</title></head><body>upstream details</body></html>',
{ status: 502, headers: { 'content-type': 'text/html' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/browser/start'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '登录服务暂时不可用,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('<html>');
});
it('opens Works Square browser authorization and returns the approved desktop token', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({
request_id: 'desktop-request-id',
device_secret: 'desktop-device-secret',
authorize_url: 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
poll_interval_seconds: 0,
}), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'pending' }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({
status: 'approved',
token: {
access_token: 'desktop-access-token',
refresh_token: 'desktop-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'student',
user_id: '42',
},
}), { status: 200 }),
);
it('has no browser-start route', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
@@ -359,74 +307,265 @@ describe('auth host api routes', () => {
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
token: {
access_token: 'desktop-access-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'student',
user_id: '42',
},
session: {
accessToken: 'desktop-access-token',
tokenType: 'Bearer',
expiresAt: expect.any(Number),
lastActiveAt: expect.any(Number),
canRefresh: true,
},
});
expect(shell.openExternal).toHaveBeenCalledWith(
'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
);
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
'https://square.nianxx.cn/api/auth/desktop/start',
'https://square.nianxx.cn/api/auth/desktop/token?request_id=desktop-request-id&device_secret=desktop-device-secret',
'https://square.nianxx.cn/api/auth/desktop/token?request_id=desktop-request-id&device_secret=desktop-device-secret',
]);
expect(response.statusCode).toBe(404);
expect(fetchMock).not.toHaveBeenCalled();
});
it('stores the approved browser token bundle in the Main Works Square session cache', async () => {
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
const fetchMock = vi.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({
request_id: 'desktop-request-id',
device_secret: 'desktop-device-secret',
authorize_url: 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
poll_interval_seconds: 0,
}), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({
status: 'approved',
token: {
access_token: 'desktop-access-token',
refresh_token: 'desktop-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
},
}), { status: 200 }),
it.each(['authBase', 'clientId', 'clientSecret', 'scope', 'passwordEncodeKey'])(
'rejects renderer-provided auth control field %s',
async (field) => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
username: 'zhangsan',
password: 'passw0rd',
[field]: 'renderer-controlled',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
{} as never,
);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
success: false,
error: `Unexpected field: ${field}`,
});
expect(fetchMock).not.toHaveBeenCalled();
},
);
it('maps Works 5xx responses to a safe login service failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
JSON.stringify({ detail: 'internal host and credential details' }),
{ status: 503 },
)));
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '登录服务暂时不可用,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('internal host');
});
it('maps Works network failures to a safe login service failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(
new Error('connect ECONNREFUSED 10.0.0.8 with password=secret'),
));
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '登录服务暂时不可用,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('10.0.0.8');
});
it('proxies mobile login and commits the returned token bundle', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'mobile-access-token',
refresh_token: 'mobile-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: '13800000000',
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST'),
createRequest('POST', { phone: '13800000000', code: '123456' }),
response.res,
new URL('http://127.0.0.1:13210/api/auth/browser/start'),
new URL('http://127.0.0.1:13210/api/auth/mobile-login'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'desktop-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
canRefresh: true,
expect(response.json()).toMatchObject({
success: true,
token: { access_token: 'mobile-access-token' },
session: { accessToken: 'mobile-access-token', canRefresh: true },
});
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
expect(JSON.stringify(response.json())).not.toContain('mobile-refresh-token');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://square.nianxx.cn/api/auth/mobile-login');
expect(JSON.parse(String(init.body))).toEqual({ phone: '13800000000', code: '123456' });
});
it('returns only success for a successful SMS send envelope', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ code: 0, msg: 'sent', data: true }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
phone: '13800000000',
imageRandomStr: '550e8400-e29b-41d4-a716-446655440000',
imageCode: '15',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-code'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://square.nianxx.cn/api/auth/mobile-code');
expect(JSON.parse(String(init.body))).toEqual({
phone: '13800000000',
imageRandomStr: '550e8400-e29b-41d4-a716-446655440000',
imageCode: '15',
});
expect(JSON.stringify(response.json())).not.toContain('sent');
});
it('maps a failed SMS success envelope to an actionable 400', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ code: 1, msg: '图形验证码不合法', data: false }), { status: 200 }),
));
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
phone: '13800000000',
imageRandomStr: '550e8400-e29b-41d4-a716-446655440000',
imageCode: 'wrong',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-code'),
{} as never,
);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ success: false, error: '图形验证码不合法' });
});
it('validates captcha UUID before contacting Works', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=not-a-uuid'),
{} as never,
);
expect(response.statusCode).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('projects a bounded PNG captcha as no-store base64 JSON', async () => {
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('captcha'),
]);
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(png, {
status: 200,
headers: { 'Content-Type': 'image/png' },
})));
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=550e8400-e29b-41d4-a716-446655440000'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
image: { mimeType: 'image/png', dataBase64: png.toString('base64') },
});
expect(response.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
});
it.each([
['wrong MIME', Buffer.from('not png'), 'image/jpeg'],
['wrong magic', Buffer.from('not png'), 'image/png'],
['too large', Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(1024 * 1024),
]), 'image/png'],
])('rejects captcha images with %s', async (_label, bytes, contentType) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes, {
status: 200,
headers: { 'Content-Type': contentType },
})));
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=550e8400-e29b-41d4-a716-446655440000'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '图形验证码服务返回了无效图片,请稍后重试。',
});
});
it('projects only safe public login links', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
downloads: { windows_url: 'https://secret.example/client.exe' },
legal: {
terms_url: '/legal/terms',
privacy_url: 'http://unsafe.example/privacy',
},
auth: {
forgot_password_url: 'https://accounts.example/forgot',
wechat_login_url: 'https://secret.example/wechat',
},
}), { status: 200 })));
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/public-config'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
links: {
termsUrl: 'https://square.nianxx.cn/legal/terms',
privacyUrl: null,
forgotPasswordUrl: 'https://accounts.example/forgot',
},
});
expect(JSON.stringify(response.json())).not.toContain('secret.example');
expect(response.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
});
it('accepts renderer session sync after app restart', async () => {
@@ -776,6 +915,40 @@ describe('auth host api routes', () => {
});
});
it('rejects a renderer-controlled logout auth base without sending the Main token', async () => {
storeWorksSquareSession({
accessToken: 'main-secret-access-token',
refreshToken: 'main-secret-refresh-token',
expiresAt: Date.now() + 3600_000,
lastActiveAt: Date.now(),
});
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const stop = vi.fn();
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
accessToken: 'renderer-stale-access-token',
authBase: 'https://attacker.example/collect',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/logout'),
{ opencodeManager: { stop } } as never,
);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
success: false,
error: 'Unexpected field: authBase',
});
expect(fetchMock).not.toHaveBeenCalled();
expect(stop).not.toHaveBeenCalled();
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'main-secret-access-token',
});
});
it('blocks a new login while cleanup of the previous runtime still fails', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), { status: 200 }),