merge: integrate secure remembered password
This commit is contained in:
@@ -11,6 +11,11 @@ import {
|
||||
storeWorksSquareSession,
|
||||
} from '@electron/services/works-square-session';
|
||||
import { resetManagedWorksSquareRuntimeForTests } from '@electron/services/works-square-runtime';
|
||||
import {
|
||||
getRememberedPasswordState,
|
||||
initializeRememberedPassword,
|
||||
resetRememberedPasswordForTests,
|
||||
} from '@electron/services/remembered-password';
|
||||
|
||||
const providerServiceMock = vi.hoisted(() => ({
|
||||
deleteAccountApiKey: vi.fn(),
|
||||
@@ -58,6 +63,7 @@ describe('auth host api routes', () => {
|
||||
vi.restoreAllMocks();
|
||||
resetWorksSquareSessionForTests();
|
||||
resetManagedWorksSquareRuntimeForTests();
|
||||
resetRememberedPasswordForTests();
|
||||
providerServiceMock.deleteAccountApiKey.mockReset();
|
||||
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
|
||||
});
|
||||
@@ -173,6 +179,11 @@ describe('auth host api routes', () => {
|
||||
});
|
||||
|
||||
it('proxies password login through Works and commits a redacted Main session', async () => {
|
||||
const credentialPersistence = {
|
||||
load: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
await initializeRememberedPassword({ persistence: credentialPersistence });
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
@@ -190,6 +201,7 @@ describe('auth host api routes', () => {
|
||||
createRequest('POST', {
|
||||
username: 'zhangsan',
|
||||
password: 'passw0rd',
|
||||
rememberPassword: true,
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/login'),
|
||||
@@ -226,11 +238,35 @@ describe('auth host api routes', () => {
|
||||
password: 'passw0rd',
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('refresh-token');
|
||||
expect(JSON.stringify(response.json())).not.toContain('passw0rd');
|
||||
expect(getWorksSquareSessionSnapshot()).toMatchObject({
|
||||
accessToken: 'access-token',
|
||||
canRefresh: true,
|
||||
});
|
||||
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
|
||||
expect(credentialPersistence.save).toHaveBeenCalledWith({
|
||||
username: 'zhangsan',
|
||||
password: 'passw0rd',
|
||||
});
|
||||
await expect(getRememberedPasswordState()).resolves.toEqual({
|
||||
available: true,
|
||||
credentials: { username: 'zhangsan', password: 'passw0rd' },
|
||||
});
|
||||
|
||||
const rememberedResponse = createResponse();
|
||||
await handleAuthRoutes(
|
||||
createRequest('GET'),
|
||||
rememberedResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/remembered-password'),
|
||||
{} as never,
|
||||
);
|
||||
expect(rememberedResponse.statusCode).toBe(200);
|
||||
expect(rememberedResponse.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
||||
expect(rememberedResponse.json()).toEqual({
|
||||
success: true,
|
||||
available: true,
|
||||
credentials: { username: 'zhangsan', password: 'passw0rd' },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows explicit reauthorization to replace an unreadable persisted session', async () => {
|
||||
@@ -268,6 +304,44 @@ describe('auth host api routes', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('clears an old remembered password after a successful unremembered password login', async () => {
|
||||
const credentialPersistence = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
username: 'old-user',
|
||||
password: 'old-password',
|
||||
}),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
await initializeRememberedPassword({ persistence: credentialPersistence });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
username: 'new-user',
|
||||
}), { status: 200 }),
|
||||
));
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', {
|
||||
username: 'new-user',
|
||||
password: 'new-password',
|
||||
rememberPassword: false,
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/login'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(credentialPersistence.save).toHaveBeenCalledWith(null);
|
||||
await expect(getRememberedPasswordState()).resolves.toEqual({
|
||||
available: true,
|
||||
credentials: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through SSO credential errors without exposing secrets', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 1, msg: 'Bad credentials', data: null }), { status: 401 }),
|
||||
|
||||
@@ -80,11 +80,16 @@ describe('auth store', () => {
|
||||
await useAuthStore.getState().loginWithPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'secret',
|
||||
rememberPassword: true,
|
||||
});
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'zhangsan', password: 'secret' }),
|
||||
body: JSON.stringify({
|
||||
username: 'zhangsan',
|
||||
password: 'secret',
|
||||
rememberPassword: true,
|
||||
}),
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalledWith(
|
||||
@@ -114,6 +119,7 @@ describe('auth store', () => {
|
||||
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
|
||||
'must-not-return-to-renderer-storage',
|
||||
);
|
||||
expect(window.localStorage.getItem('niancode-auth')).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('logs in with a mobile code using only the phone and code payload', async () => {
|
||||
|
||||
@@ -86,6 +86,9 @@ describe('Login page', () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === '/api/auth/remembered-password') {
|
||||
return { success: true, available: true, credentials: null };
|
||||
}
|
||||
if (path.startsWith('/api/auth/mobile-image-code?')) return resolveCaptcha();
|
||||
return { success: true };
|
||||
});
|
||||
@@ -105,9 +108,31 @@ describe('Login page', () => {
|
||||
expect(screen.getByRole('tab', { name: '验证码登录' })).toHaveAttribute('aria-selected', 'false');
|
||||
expect(screen.getByLabelText('用户名')).toHaveAttribute('autocomplete', 'username');
|
||||
expect(screen.getByLabelText('密码')).toHaveAttribute('autocomplete', 'current-password');
|
||||
expect(await screen.findByRole('checkbox', { name: '记住密码' })).toBeEnabled();
|
||||
expect(screen.queryByText(/浏览器|微信|注册/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores OS-protected password credentials and keeps remember password selected', async () => {
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/auth/public-config') return { success: true, links: {} };
|
||||
if (path === '/api/auth/remembered-password') {
|
||||
return {
|
||||
success: true,
|
||||
available: true,
|
||||
credentials: { username: 'remembered-user', password: 'remembered-secret' },
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('用户名')).toHaveValue('remembered-user'));
|
||||
expect(screen.getByLabelText('密码')).toHaveValue('remembered-secret');
|
||||
expect(screen.getByRole('checkbox', { name: '记住密码' })).toBeChecked();
|
||||
expect(window.localStorage.getItem('niancode-auth')).not.toContain('remembered-secret');
|
||||
});
|
||||
|
||||
it('renders only safe projected links and degrades unavailable or unsafe links to plain text', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
@@ -120,8 +145,9 @@ describe('Login page', () => {
|
||||
renderLogin();
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/public-config', { cache: 'no-store' }));
|
||||
expect(screen.getByRole('checkbox').closest('label')).toHaveTextContent('用户协议');
|
||||
expect(screen.getByRole('checkbox').closest('label')).toHaveTextContent('隐私政策');
|
||||
const agreement = screen.getByRole('checkbox', { name: /我已阅读并同意/ });
|
||||
expect(agreement.closest('label')).toHaveTextContent('用户协议');
|
||||
expect(agreement.closest('label')).toHaveTextContent('隐私政策');
|
||||
expect(screen.queryByText('忘记密码?')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -144,14 +170,19 @@ describe('Login page', () => {
|
||||
|
||||
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: ' zhangsan ' } });
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: '记住密码' }));
|
||||
const submit = screen.getByRole('button', { name: '登录' });
|
||||
expect(submit).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
|
||||
expect(submit).toBeEnabled();
|
||||
fireEvent.click(submit);
|
||||
|
||||
await screen.findByText('Module Selection');
|
||||
expect(loginWithPassword).toHaveBeenCalledWith({ username: 'zhangsan', password: 'secret-password' });
|
||||
expect(loginWithPassword).toHaveBeenCalledWith({
|
||||
username: 'zhangsan',
|
||||
password: 'secret-password',
|
||||
rememberPassword: true,
|
||||
});
|
||||
expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token');
|
||||
});
|
||||
|
||||
@@ -166,7 +197,7 @@ describe('Login page', () => {
|
||||
expect(screen.getByLabelText('短信验证码')).toHaveAttribute('autocomplete', 'one-time-code');
|
||||
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '20123456789' } });
|
||||
fireEvent.change(screen.getByLabelText('短信验证码'), { target: { value: '123456' } });
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
|
||||
expect(screen.getByRole('button', { name: '登录' })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
|
||||
@@ -327,7 +358,7 @@ describe('Login page', () => {
|
||||
renderLogin();
|
||||
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } });
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请稍后重试。');
|
||||
|
||||
95
tests/unit/remembered-password-persistence.test.ts
Normal file
95
tests/unit/remembered-password-persistence.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { safeStorage } from 'electron';
|
||||
|
||||
const fakeStore = vi.hoisted(() => ({
|
||||
options: null as Record<string, unknown> | null,
|
||||
values: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock('electron-store', () => ({
|
||||
default: class FakeElectronStore {
|
||||
constructor(options: Record<string, unknown>) {
|
||||
fakeStore.options = options;
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return fakeStore.values.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
fakeStore.values.set(key, value);
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
fakeStore.values.delete(key);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('remembered password persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fakeStore.options = null;
|
||||
fakeStore.values.clear();
|
||||
});
|
||||
|
||||
it('stores only an OS-encrypted Main record and restores the credentials', async () => {
|
||||
const firstModule = await import('@electron/services/remembered-password');
|
||||
await firstModule.initializeRememberedPassword({ secureStorage: safeStorage });
|
||||
|
||||
await expect(firstModule.updateRememberedPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'plain-password',
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(fakeStore.options).toMatchObject({
|
||||
name: 'remembered-password',
|
||||
configFileMode: 0o600,
|
||||
});
|
||||
const serializedStore = JSON.stringify(Object.fromEntries(fakeStore.values));
|
||||
expect(serializedStore).not.toContain('zhangsan');
|
||||
expect(serializedStore).not.toContain('plain-password');
|
||||
|
||||
vi.resetModules();
|
||||
const restoredModule = await import('@electron/services/remembered-password');
|
||||
await restoredModule.initializeRememberedPassword({ secureStorage: safeStorage });
|
||||
await expect(restoredModule.getRememberedPasswordState()).resolves.toEqual({
|
||||
available: true,
|
||||
credentials: {
|
||||
username: 'zhangsan',
|
||||
password: 'plain-password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not offer persistence without OS credential encryption', async () => {
|
||||
const passwordModule = await import('@electron/services/remembered-password');
|
||||
await passwordModule.initializeRememberedPassword();
|
||||
|
||||
await expect(passwordModule.updateRememberedPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'plain-password',
|
||||
})).resolves.toBe(false);
|
||||
await expect(passwordModule.getRememberedPasswordState()).resolves.toEqual({
|
||||
available: false,
|
||||
credentials: null,
|
||||
});
|
||||
expect(fakeStore.options).toBeNull();
|
||||
});
|
||||
|
||||
it('discards an unreadable record without permanently disabling the feature', async () => {
|
||||
const passwordModule = await import('@electron/services/remembered-password');
|
||||
const persistence = {
|
||||
load: vi.fn().mockRejectedValue(new Error('credential cannot be decrypted')),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await passwordModule.initializeRememberedPassword({ persistence });
|
||||
|
||||
expect(persistence.save).toHaveBeenCalledWith(null);
|
||||
await expect(passwordModule.getRememberedPasswordState()).resolves.toEqual({
|
||||
available: true,
|
||||
credentials: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user