merge: integrate secure remembered password

This commit is contained in:
2026-08-20 23:41:48 +08:00
11 changed files with 530 additions and 17 deletions

View File

@@ -0,0 +1,53 @@
# Task: Add remember password to Makelore client
## Identity
- Task ID: 20260820-makelore-remember-password-b63e1c
- Mode: Feature
- Branch: codex/20260820-makelore-remember-password-b63e1c-remember-password
- Worktree: D:\w\makelore-remember-password-b63e1c
- Base commit: 1c6b00443631ad41929e79943bbea9bc7126b563
- Owner: codex
- Status: Ready for Integration
## Scope
- Add an optional remember-password control to the native password login form.
- Keep remembered credentials in Electron Main and encrypt them with Electron `safeStorage` before writing them to disk.
- Restore remembered credentials on the login page without adding passwords to Renderer persistence or sending the remember flag upstream.
- Cover secure persistence, Host API projection, and login-page behavior with focused tests.
## Intent And Constraints
- The feature remembers the username and password themselves; it is separate from the existing seven-day Main-owned login session.
- Works Square remains the authentication authority and must not persist the desktop password.
- Secure persistence is available only where the existing packaged-app credential policy enables OS encryption. Unpackaged development remains memory-only and exposes the control as unavailable.
- SMS login does not modify a previously remembered password. A successful password login with the control cleared removes the remembered credentials.
- Do not add password fields to Zustand/localStorage, logs, or the public token/session projection.
## Outcome
- Added a native “记住密码” option to password login. The Renderer reads the Main projection for prefill but does not persist the username or password.
- Added an independent Electron Main credential store backed by `safeStorage` and `electron-store`. The disk record contains only encrypted ciphertext and is enabled only by the existing packaged-app secure credential policy.
- Successful password login saves or clears the record according to the checkbox without forwarding `rememberPassword` to Works Square. SMS login and logout leave the remembered password unchanged.
- Unreadable encrypted records are discarded without permanently disabling future saves. An unavailable secure-storage backend projects the feature as disabled.
- Updated README product and architecture descriptions.
## Verification
- `pnpm exec vitest run tests/unit/remembered-password-persistence.test.ts tests/unit/auth-routes.test.ts tests/unit/login-page.test.tsx` — 3 files / 54 tests passed.
- `pnpm exec vitest run tests/unit/auth-store.test.ts` — 1 file / 25 tests passed.
- `pnpm run typecheck` — passed.
- `pnpm exec eslint electron/services/remembered-password.ts electron/api/routes/auth.ts electron/main/index.ts src/pages/Login/index.tsx src/stores/auth.ts tests/unit/remembered-password-persistence.test.ts tests/unit/auth-routes.test.ts tests/unit/login-page.test.tsx` — passed.
- `pnpm exec eslint tests/unit/auth-store.test.ts` — passed.
- `pnpm run build:vite` — passed for Renderer, Electron Main, preload, and utility worker. Existing Vite dynamic-import and large-chunk warnings remain unchanged.
- `git diff --check` — passed; Git reported only the repository's LF-to-CRLF checkout warning.
## Follow-ups
- Run one packaged Windows and signed macOS smoke to confirm OS-protected save, app restart prefill, successful unremembered-login clear, and the absence of a development Keychain prompt. No packaged artifact or real-account smoke was run in this task.
## Promotion Candidates
- On integration, add the remembered-password boundary to `30-worklog/current-state.md` and `40-domain/business-rules.md`: Main-only OS-encrypted persistence, packaged-app availability, logout preservation, and successful unchecked-password-login clearing.
- Consider extending ADR-004 with the separate optional password-record lifecycle; the existing token lifecycle and seven-day inactivity decision remain unchanged.

View File

@@ -9,7 +9,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人
- `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
- `Makelore LearningAI 学习`:浏览运营精选的学习项目,阅读项目 README并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”小游戏和小程序会创建完整的平台发布模板项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
@@ -94,7 +94,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
- Renderer 的后端调用统一经过 `src/lib/host-api.ts``src/lib/api-client.ts`;请求先经 Main-owned IPC再由兼容 Host API 路由处理。只有真正需要 URL 的资源和流会把 loopback 地址暴露给 Renderer。
- Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。
- Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成;所有 stream、watcher、poller、loopback server 与子进程必须登记到模块活动和任务租约,不允许页面自行创建无托管后台任务。
- Works Square 原生密码与短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;未打包开发版只在内存持有凭据Main 重启后需要重新登录,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变旧版升级迁移时仅暂存既有刷新凭据Main 成功接管后立即删除)。
- Works Square 原生密码与短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;可选的记住密码记录使用独立的 Main-owned 加密存储,退出登录不会清除它,只有成功的未勾选密码登录才清除旧记录。未打包开发版只在内存持有会话且禁用记住密码,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变旧版升级迁移时仅暂存既有刷新凭据Main 成功接管后立即删除),账号密码不进入 Renderer 持久状态
- AI 编程发布只经过 Main-owned Host APIRenderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTOMain 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。发布构建同时提供 Main-owned `ReleaseJob` 的 start/progress/status/cancel 契约,同一项目串行执行并支持取消;异步 Job 的扫描、依赖安装、构建和双归档均在独立 `utilityProcess` 中以流式文件处理Main 只接收进度、摘要和契约,旧的同步提交接口继续兼容已有客户端。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。
- AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。
- AI 绘画 Renderer 只调用 Main-owned Host APIMain 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。

View File

@@ -23,10 +23,15 @@ import {
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 = {
@@ -219,6 +224,7 @@ function sendUpstreamFailure(
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: '登录服务返回了无效响应,请稍后重试。' });
@@ -228,6 +234,7 @@ async function commitLoginPayload(
const session = await commitWorksSquareSessionFromTokenPayload(
payload as WorksSquareTokenPayload,
);
await onCommitted?.();
sendJson(res, 200, {
success: true,
token: withoutRefreshToken(payload),
@@ -246,10 +253,11 @@ async function handlePasswordLogin(
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
const body = readExactJsonObject(
await parseJsonBody<PasswordLoginInput>(req),
['username', 'password'],
['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' },
@@ -265,7 +273,17 @@ async function handlePasswordLogin(
);
return;
}
await commitLoginPayload(res, result.payload);
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(
@@ -717,6 +735,11 @@ export async function handleAuthRoutes(
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;

View File

@@ -85,6 +85,7 @@ import {
type WorksSquareSessionChangeReason,
} from '../services/works-square-session';
import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy';
import { initializeRememberedPassword } from '../services/remembered-password';
import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime';
import { getPort } from '../utils/config';
import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets';
@@ -560,6 +561,9 @@ async function initialize(): Promise<void> {
};
registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext);
void initializeRememberedPassword({
secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null,
});
hostApiServer = startHostApiServer(hostApiContext);
// Register update handlers

View File

@@ -0,0 +1,171 @@
import { NIANCODE_AUTH_GATEWAY_URL } from '../../shared/auth-public';
import { logger } from '../utils/logger';
import type { WorksSquareSecureStorage } from './works-square-session';
const STORE_SCHEMA_VERSION = 1;
export type RememberedPassword = Readonly<{
username: string;
password: string;
}>;
export interface RememberedPasswordPersistence {
load(): Promise<unknown>;
save(credentials: RememberedPassword | null): Promise<void>;
}
export type RememberedPasswordState = Readonly<{
available: boolean;
credentials: RememberedPassword | null;
}>;
let persistence: RememberedPasswordPersistence | null = null;
let rememberedPassword: RememberedPassword | null = null;
let initialization: Promise<void> = Promise.resolve();
function normalizeCredentials(value: unknown): RememberedPassword | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.username !== 'string'
|| !candidate.username.trim()
|| typeof candidate.password !== 'string'
|| !candidate.password
) {
return null;
}
return {
username: candidate.username.trim(),
password: candidate.password,
};
}
async function createElectronPersistence(
safeStorage: WorksSquareSecureStorage,
): Promise<RememberedPasswordPersistence | null> {
const usesUnprotectedLinuxBackend = process.platform === 'linux'
&& safeStorage.getSelectedStorageBackend() === 'basic_text';
if (!safeStorage.isEncryptionAvailable() || usesUnprotectedLinuxBackend) {
logger.warn('[remembered-password] OS credential encryption is unavailable; persistence disabled');
return null;
}
const { default: Store } = await import('electron-store');
type EncryptedPasswordRecord = {
version: number;
authBase: string;
ciphertext: string;
};
const storeOptions = {
name: 'remembered-password',
configFileMode: 0o600,
} as const;
let store: Store<{ record?: EncryptedPasswordRecord }>;
try {
store = new Store<{ record?: EncryptedPasswordRecord }>(storeOptions);
} catch (error) {
logger.warn('[remembered-password] Invalid credential store config; discarding it', error);
store = new Store<{ record?: EncryptedPasswordRecord }>({
...storeOptions,
clearInvalidConfig: true,
});
}
return {
async load() {
const record = store.get('record');
if (!record) return null;
if (
record.version !== STORE_SCHEMA_VERSION
|| record.authBase !== NIANCODE_AUTH_GATEWAY_URL
) {
store.delete('record');
return null;
}
return JSON.parse(
safeStorage.decryptString(Buffer.from(record.ciphertext, 'base64')),
) as unknown;
},
async save(credentials) {
if (!credentials) {
store.delete('record');
return;
}
const ciphertext = safeStorage.encryptString(JSON.stringify(credentials));
store.set('record', {
version: STORE_SCHEMA_VERSION,
authBase: NIANCODE_AUTH_GATEWAY_URL,
ciphertext: ciphertext.toString('base64'),
});
},
};
}
export function initializeRememberedPassword(
options: {
persistence?: RememberedPasswordPersistence;
secureStorage?: WorksSquareSecureStorage | null;
} = {},
): Promise<void> {
initialization = (async () => {
rememberedPassword = null;
try {
persistence = options.persistence
?? (options.secureStorage ? await createElectronPersistence(options.secureStorage) : null);
} catch (error) {
logger.warn('[remembered-password] Failed to initialize secure credential persistence', error);
persistence = null;
return;
}
if (!persistence) return;
try {
const loaded = await persistence.load();
const normalized = normalizeCredentials(loaded);
if (loaded && !normalized) {
await persistence.save(null);
}
rememberedPassword = normalized;
} catch (error) {
logger.warn('[remembered-password] Failed to restore credentials; discarding the record', error);
try {
await persistence.save(null);
} catch (clearError) {
logger.warn('[remembered-password] Failed to discard unreadable credentials', clearError);
persistence = null;
}
}
})();
return initialization;
}
export async function getRememberedPasswordState(): Promise<RememberedPasswordState> {
await initialization;
return {
available: persistence !== null,
credentials: rememberedPassword ? { ...rememberedPassword } : null,
};
}
export async function updateRememberedPassword(
credentials: RememberedPassword | null,
): Promise<boolean> {
await initialization;
if (!persistence) return false;
const normalized = credentials ? normalizeCredentials(credentials) : null;
if (credentials && !normalized) return false;
try {
await persistence.save(normalized);
rememberedPassword = normalized;
return true;
} catch (error) {
logger.warn('[remembered-password] Failed to update secure credentials', error);
return false;
}
}
export function resetRememberedPasswordForTests(): void {
persistence = null;
rememberedPassword = null;
initialization = Promise.resolve();
}

View File

@@ -42,6 +42,15 @@ type SmsResponse = {
error?: unknown;
};
type RememberedPasswordResponse = {
success?: unknown;
available?: unknown;
credentials?: {
username?: unknown;
password?: unknown;
} | null;
};
const EMPTY_LINKS: PublicLinks = {
termsUrl: null,
privacyUrl: null,
@@ -104,6 +113,8 @@ export function Login() {
const [mode, setMode] = useState<LoginMode>('password');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rememberPassword, setRememberPassword] = useState(false);
const [rememberPasswordAvailable, setRememberPasswordAvailable] = useState(false);
const [phone, setPhone] = useState('');
const [imageCode, setImageCode] = useState('');
const [smsCode, setSmsCode] = useState('');
@@ -123,13 +134,41 @@ export function Login() {
useEffect(() => {
let active = true;
void hostApiFetch<PublicConfigResponse>('/api/auth/public-config', { cache: 'no-store' })
.then((response) => {
void (async () => {
try {
const response = await hostApiFetch<PublicConfigResponse>(
'/api/auth/public-config',
{ cache: 'no-store' },
);
if (active) setLinks(projectPublicLinks(response));
})
.catch(() => {
} catch {
if (active) setLinks(EMPTY_LINKS);
});
}
try {
const response = await hostApiFetch<RememberedPasswordResponse>(
'/api/auth/remembered-password',
{ cache: 'no-store' },
);
if (!active) return;
const available = response.success === true && response.available === true;
setRememberPasswordAvailable(available);
if (
available
&& response.credentials
&& typeof response.credentials.username === 'string'
&& response.credentials.username.trim()
&& typeof response.credentials.password === 'string'
&& response.credentials.password
) {
setUsername(response.credentials.username.trim());
setPassword(response.credentials.password);
setRememberPassword(true);
}
} catch {
if (active) setRememberPasswordAvailable(false);
}
})();
return () => {
active = false;
};
@@ -229,7 +268,11 @@ export function Login() {
const handlePasswordSubmit = (event: FormEvent) => {
event.preventDefault();
if (!agreed || !username.trim() || !password) return;
void finishLogin(() => loginWithPassword({ username: username.trim(), password }));
void finishLogin(() => loginWithPassword({
username: username.trim(),
password,
rememberPassword,
}));
};
const handleMobileSubmit = (event: FormEvent) => {
@@ -349,6 +392,18 @@ export function Login() {
</div>
<Input id="login-password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} />
</div>
<label
className="flex w-fit items-center gap-2 text-sm text-muted-foreground"
title={rememberPasswordAvailable ? undefined : '当前环境无法使用系统安全存储'}
>
<input
type="checkbox"
checked={rememberPassword}
disabled={!rememberPasswordAvailable}
onChange={(event) => setRememberPassword(event.target.checked)}
/>
<span></span>
</label>
<Button type="submit" className="w-full" disabled={!passwordReady}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

View File

@@ -67,6 +67,7 @@ type RefreshSessionOptions = {
type PasswordLoginInput = {
username: string;
password: string;
rememberPassword?: boolean;
};
type MobileLoginInput = {

View File

@@ -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 }),

View File

@@ -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 () => {

View File

@@ -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('登录失败,请稍后重试。');

View 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,
});
});
});