feat(auth): remember password securely
This commit is contained in:
@@ -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.
|
||||
@@ -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。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
171
electron/services/remembered-password.ts
Normal file
171
electron/services/remembered-password.ts
Normal 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();
|
||||
}
|
||||
@@ -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" />}
|
||||
登录
|
||||
|
||||
@@ -67,6 +67,7 @@ type RefreshSessionOptions = {
|
||||
type PasswordLoginInput = {
|
||||
username: string;
|
||||
password: string;
|
||||
rememberPassword?: boolean;
|
||||
};
|
||||
|
||||
type MobileLoginInput = {
|
||||
|
||||
@@ -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