From 990639f7704cb78c45dd4ac302a06f4fbaaf62c9 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 20 Aug 2026 23:41:33 +0800 Subject: [PATCH] feat(auth): remember password securely --- ...60820-makelore-remember-password-b63e1c.md | 53 ++++++ README.md | 4 +- electron/api/routes/auth.ts | 27 ++- electron/main/index.ts | 4 + electron/services/remembered-password.ts | 171 ++++++++++++++++++ src/pages/Login/index.tsx | 67 ++++++- src/stores/auth.ts | 1 + tests/unit/auth-routes.test.ts | 74 ++++++++ tests/unit/auth-store.test.ts | 8 +- tests/unit/login-page.test.tsx | 43 ++++- .../remembered-password-persistence.test.ts | 95 ++++++++++ 11 files changed, 530 insertions(+), 17 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260820-makelore-remember-password-b63e1c.md create mode 100644 electron/services/remembered-password.ts create mode 100644 tests/unit/remembered-password-persistence.test.ts diff --git a/.project-docs/30-worklog/tasks/20260820-makelore-remember-password-b63e1c.md b/.project-docs/30-worklog/tasks/20260820-makelore-remember-password-b63e1c.md new file mode 100644 index 0000000..835f870 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260820-makelore-remember-password-b63e1c.md @@ -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. diff --git a/README.md b/README.md index 9d1edd3..c09ff2f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 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 API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 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 API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index 4679d93..2d57ca7 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -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, ): Promise { 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(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 { + 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; diff --git a/electron/main/index.ts b/electron/main/index.ts index 8482bd8..63ead60 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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 { }; registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext); + void initializeRememberedPassword({ + secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null, + }); hostApiServer = startHostApiServer(hostApiContext); // Register update handlers diff --git a/electron/services/remembered-password.ts b/electron/services/remembered-password.ts new file mode 100644 index 0000000..569d4c1 --- /dev/null +++ b/electron/services/remembered-password.ts @@ -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; + save(credentials: RememberedPassword | null): Promise; +} + +export type RememberedPasswordState = Readonly<{ + available: boolean; + credentials: RememberedPassword | null; +}>; + +let persistence: RememberedPasswordPersistence | null = null; +let rememberedPassword: RememberedPassword | null = null; +let initialization: Promise = Promise.resolve(); + +function normalizeCredentials(value: unknown): RememberedPassword | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Record; + 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 { + 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 { + 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 { + await initialization; + return { + available: persistence !== null, + credentials: rememberedPassword ? { ...rememberedPassword } : null, + }; +} + +export async function updateRememberedPassword( + credentials: RememberedPassword | null, +): Promise { + 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(); +} diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index a45ec4b..75965fb 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -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('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('/api/auth/public-config', { cache: 'no-store' }) - .then((response) => { + void (async () => { + try { + const response = await hostApiFetch( + '/api/auth/public-config', + { cache: 'no-store' }, + ); if (active) setLinks(projectPublicLinks(response)); - }) - .catch(() => { + } catch { if (active) setLinks(EMPTY_LINKS); - }); + } + + try { + const response = await hostApiFetch( + '/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() { setPassword(event.target.value)} /> +