feat: add native password and sms login
This commit is contained in:
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user