From b65e32885f905ca58772f12f4e5ad47bbf0c3644 Mon Sep 17 00:00:00 2001 From: JustMe Date: Tue, 13 Oct 2026 01:39:24 +0800 Subject: [PATCH] feat(auth): remember encrypted local login credentials --- web/src/core-bridge/account.ts | 8 ++-- web/src/core-bridge/worker.ts | 8 ++-- web/src/core-bridge/worker/account.ts | 45 +++++++++++++------ .../core-bridge/worker/accountPersistence.ts | 15 ++++++- .../worker/credentialCipher.test.ts | 24 ++++++++++ .../core-bridge/worker/credentialCipher.ts | 15 +++++++ web/src/features/home/AccountPanel.module.css | 12 +++++ web/src/features/home/AccountPanel.tsx | 37 ++++++++++++--- web/src/store/account.ts | 14 ++++-- 9 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 web/src/core-bridge/worker/credentialCipher.test.ts create mode 100644 web/src/core-bridge/worker/credentialCipher.ts diff --git a/web/src/core-bridge/account.ts b/web/src/core-bridge/account.ts index 94bef6e..c8f5dc5 100644 --- a/web/src/core-bridge/account.ts +++ b/web/src/core-bridge/account.ts @@ -2,13 +2,15 @@ import { callWorker } from './workerClient'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; -export interface AccountStatus { username: string | null; revision: number; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string } +export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string } export interface AccountDevice { id: string; name: string; created_at: number; last_seen: number; current: boolean } export interface AccountInvitation { channel_id: string; username: string; payload: string; created_at: number } export const accountApi = { - register: (username: string, password: string) => callWorker('registerAccount', { username, password, apiBase: API_BASE }), - login: (username: string, password: string) => callWorker('loginAccount', { username, password, apiBase: API_BASE }), + register: (username: string, password: string, remember = true) => callWorker('registerAccount', { username, password, remember, apiBase: API_BASE }), + login: (username: string, password: string, remember = true) => callWorker('loginAccount', { username, password, remember, apiBase: API_BASE }), + savedLogin: () => callWorker<{ username: string; password: string; saved: boolean }>('savedLogin', { apiBase: API_BASE }), + clearSavedLogin: () => callWorker('clearSavedLogin', { apiBase: API_BASE }), status: () => callWorker('accountStatus', { apiBase: API_BASE }), logout: () => callWorker('logoutAccount', { apiBase: API_BASE }), sync: () => callWorker('syncAccount', { apiBase: API_BASE }), diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index 1904db3..1607b6c 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -12,7 +12,7 @@ import { serial } from './worker/serial'; import { errorMessage } from './errors'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread } from './worker/channelThreads'; -import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice } from './worker/account'; +import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } interface WorkerScope { @@ -33,8 +33,10 @@ async function dispatch(operation: string, input: unknown): Promise { case 'accountDevices': return listAccountDevices(); case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true; case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus(); - case 'registerAccount': return registerAccount(String(payload.username ?? ''), String(payload.password ?? '')); - case 'loginAccount': return loginAccount(String(payload.username ?? ''), String(payload.password ?? '')); + case 'registerAccount': return registerAccount(String(payload.username ?? ''), String(payload.password ?? ''), payload.remember !== false); + case 'loginAccount': return loginAccount(String(payload.username ?? ''), String(payload.password ?? ''), payload.remember !== false); + case 'savedLogin': return getSavedLogin(); + case 'clearSavedLogin': await clearSavedLogin(); return accountStatus(); case 'accountStatus': return accountStatus(); case 'logoutAccount': await logoutAccount(); return true; case 'syncAccount': await syncAccount(); return accountStatus(); diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts index 71c64a3..0fc6b0e 100644 --- a/web/src/core-bridge/worker/account.ts +++ b/web/src/core-bridge/worker/account.ts @@ -7,11 +7,12 @@ import { mergeVault, validateVault, validatePersonal, type VaultProfile, type Pe import { joinChannel } from './channelResources'; import { openChannelGrant, sealChannelGrant } from './channelGrant'; import { recoverAccountChannels } from './accountChannels'; -import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName } from './accountPersistence'; +import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence'; interface VaultBody { revision: number; vault: string } interface AccountSession { username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number; + remember: boolean; controller: AbortController; dirty: boolean; syncing?: Promise; error?: string; timer?: ReturnType; polling?: ReturnType; } @@ -31,6 +32,10 @@ async function applyProfile(profile: VaultProfile): Promise { await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource))); await importResourceDeletes(profile.deletes); } +async function persistSession(session: AccountSession, value: VaultBody): Promise { + if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, ...value }); + else await forgetAccount(); +} async function syncVault(): Promise { if (!account) throw new Error('请先登录账号'); const session = account; @@ -48,7 +53,7 @@ async function syncVault(): Promise { stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = remote.revision; session.error = undefined; setAccountIdentity(session, merged.keys.publicKey, merged.personal); - await rememberAccount({ username: session.username, token: session.token, key: session.key, ...remote }); + await persistSession(session, remote); break; } const vault = await sealJson(merged, session.key); @@ -60,7 +65,7 @@ async function syncVault(): Promise { await applyProfile(merged); session.profile = merged; session.revision = result.revision; session.error = undefined; setAccountIdentity(session, merged.keys.publicKey, merged.personal); - await rememberAccount({ username: session.username, token: session.token, key: session.key, vault, revision: result.revision }); + await persistSession(session, { vault, revision: result.revision }); break; } catch (error) { if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error; @@ -73,16 +78,17 @@ async function syncVault(): Promise { account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); void forgetAccount(); + void forgetLoginFields(); } throw error; }).finally(() => { session.syncing = undefined; }); return session.syncing; } -async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody): Promise { +async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise { const profile = validateVault(await openJson(remote.vault, key)); clearResourceSessions(); setResourceScope(identity.username); - const session: AccountSession = { ...identity, key, profile, revision: remote.revision, controller: new AbortController(), dirty: false }; + const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false }; account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal); await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes())); setResourceChangeListener(scheduleSync); @@ -98,7 +104,7 @@ export function restoreAccount(): Promise { try { remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise); } catch (error) { - if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); return; } + if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); await forgetLoginFields(); return; } remote = saved; } try { @@ -117,7 +123,7 @@ function scheduleSync(): void { account.timer = setTimeout(() => { void syncVault().catch(() => {}); }, 700); } -export async function registerAccount(input: string, password: string): Promise>> { +export async function registerAccount(input: string, password: string, remember = true): Promise>> { if (account) throw new Error('请先退出当前账号'); const username = usernameOf(input); if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符'); @@ -128,9 +134,9 @@ export async function registerAccount(input: string, password: string): Promise< await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) }); } finally { verifier.fill(0); } - return loginAccount(username, password); + return loginAccount(username, password, remember); } -export async function loginAccount(input: string, password: string): Promise>> { +export async function loginAccount(input: string, password: string, remember = true): Promise>> { if (account) throw new Error('请先退出当前账号'); const username = usernameOf(input); const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) @@ -148,8 +154,9 @@ export async function loginAccount(input: string, password: string): Promise r.json() as Promise<{ public_key: string }>); const published = JSON.parse(publicKey.public_key) as JsonWebKey; if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配'); - await activate(identity, key, remote); - await rememberAccount({ ...identity, key, ...remote }); + const session = await activate(identity, key, remote, remember); + if (remember) await saveLoginFields(username, password); else await forgetLoginFields(); + await persistSession(session, remote); await syncVault().catch(() => {}); return accountStatus(); } catch (error) { @@ -158,8 +165,9 @@ export async function loginAccount(input: string, password: string): Promise { +export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string }> { return { username: account?.username ?? null, revision: account?.revision ?? 0, + remembered: account?.remember ?? false, ...(account?.profile.personal ? { personal: account.profile.personal } : {}), ...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) }; } @@ -171,12 +179,23 @@ export async function logoutAccount(): Promise { if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {}); } export async function syncAccount(): Promise { await syncVault(); } +export async function getSavedLogin(): Promise<{ username: string; password: string; saved: boolean }> { + if (account) return { username: '', password: '', saved: false }; + const fields = await savedLoginFields(); return fields ? { ...fields, saved: true } : { username: '', password: '', saved: false }; +} +export async function clearSavedLogin(): Promise { + if (account) { + account.remember = false; + await account.syncing?.catch(() => {}); + } + await forgetAccount(); await forgetLoginFields(); +} export async function updatePersonal(displayName: string, avatar?: string): Promise { if (!account) throw new Error('请先登录账号'); account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) }; setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal); // Retain an offline profile edit for the next session restoration. - await rememberAccount({ username: account.username, token: account.token, key: account.key, vault: await sealJson(account.profile, account.key), revision: account.revision }); + await persistSession(account, { vault: await sealJson(account.profile, account.key), revision: account.revision }); await syncVault(); } export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); } diff --git a/web/src/core-bridge/worker/accountPersistence.ts b/web/src/core-bridge/worker/accountPersistence.ts index 8b60808..b0811ae 100644 --- a/web/src/core-bridge/worker/accountPersistence.ts +++ b/web/src/core-bridge/worker/accountPersistence.ts @@ -1,5 +1,6 @@ import { openDB, type DBSchema } from 'idb'; import { apiPath } from './http'; +import { decryptLogin, encryptLogin, type EncryptedLogin, type LoginFields } from './credentialCipher'; export interface RememberedAccount { username: string; @@ -9,16 +10,26 @@ export interface RememberedAccount { revision: number; } interface LocalIdentity extends DBSchema { + credentials: { key: string; value: EncryptedLogin }; sessions: { key: string; value: RememberedAccount }; device: { key: string; value: { id: string; name: string } }; } -const database = () => openDB('webdrop-account-local-2', 1, { upgrade(db) { - db.createObjectStore('sessions'); db.createObjectStore('device'); +const database = () => openDB('webdrop-account-local-2', 2, { upgrade(db, oldVersion) { + if (oldVersion < 1) { db.createObjectStore('sessions'); db.createObjectStore('device'); } + if (oldVersion < 2) db.createObjectStore('credentials'); } }); const endpoint = () => apiPath('/'); export async function rememberedAccount(): Promise { return (await database()).get('sessions', endpoint()); } export async function rememberAccount(value: RememberedAccount): Promise { await (await database()).put('sessions', value, endpoint()); } export async function forgetAccount(): Promise { await (await database()).delete('sessions', endpoint()); } +export async function saveLoginFields(username: string, password: string): Promise { + const value = await encryptLogin({ username, password }, endpoint()); await (await database()).put('credentials', value, endpoint()); +} +export async function forgetLoginFields(): Promise { await (await database()).delete('credentials', endpoint()); } +export async function savedLoginFields(): Promise { + const value = await (await database()).get('credentials', endpoint()); if (!value) return undefined; + try { return await decryptLogin(value, endpoint()); } catch { await forgetLoginFields(); return undefined; } +} export async function localAccountDevice(): Promise<{ id: string; name: string }> { const db = await database(); const existing = await db.get('device', endpoint()); diff --git a/web/src/core-bridge/worker/credentialCipher.test.ts b/web/src/core-bridge/worker/credentialCipher.test.ts new file mode 100644 index 0000000..2eb6717 --- /dev/null +++ b/web/src/core-bridge/worker/credentialCipher.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { decryptLogin, encryptLogin } from './credentialCipher'; + +describe('local encrypted login fields', () => { + const endpoint = 'https://drop.test/api/v1/'; + const fields = { username: 'alice', password: 'a-test-password' }; + it('restores fields using a non-exportable key while keeping plaintext out of the record', async () => { + const value = await encryptLogin(fields, endpoint); + expect(value.key.extractable).toBe(false); + expect(new TextDecoder().decode(value.ciphertext)).not.toContain(fields.password); + expect(await decryptLogin(value, endpoint)).toEqual(fields); + await expect(crypto.subtle.exportKey('raw', value.key)).rejects.toThrow(); + }); + it('binds a saved credential to its API server and rejects tampering', async () => { + const value = await encryptLogin(fields, endpoint); + await expect(decryptLogin(value, 'https://other.test/api/v1/')).rejects.toThrow(); + const bytes = new Uint8Array(value.ciphertext); bytes[0] = (bytes[0] ?? 0) ^ 1; + await expect(decryptLogin({ ...value, ciphertext: bytes.buffer }, endpoint)).rejects.toThrow(); + }); + it('uses independent random encryption material for repeated saves', async () => { + const first = await encryptLogin(fields, endpoint); const second = await encryptLogin(fields, endpoint); + expect(first.iv).not.toEqual(second.iv); expect(new Uint8Array(first.ciphertext)).not.toEqual(new Uint8Array(second.ciphertext)); + }); +}); diff --git a/web/src/core-bridge/worker/credentialCipher.ts b/web/src/core-bridge/worker/credentialCipher.ts new file mode 100644 index 0000000..3289075 --- /dev/null +++ b/web/src/core-bridge/worker/credentialCipher.ts @@ -0,0 +1,15 @@ +export interface EncryptedLogin { key: CryptoKey; iv: Uint8Array; ciphertext: ArrayBuffer } +export interface LoginFields { username: string; password: string } +const encoder = new TextEncoder(); +export async function encryptLogin(fields: LoginFields, endpoint: string): Promise { + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: encoder.encode(`webdrop-login:${endpoint}`) }, key, encoder.encode(JSON.stringify(fields))); + return { key, iv, ciphertext }; +} +export async function decryptLogin(value: EncryptedLogin, endpoint: string): Promise { + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Uint8Array.from(value.iv).buffer, additionalData: encoder.encode(`webdrop-login:${endpoint}`) }, value.key, value.ciphertext); + const fields = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext)) as LoginFields; + if (typeof fields.username !== 'string' || !/^[a-z0-9_-]{3,32}$/.test(fields.username) || typeof fields.password !== 'string' || fields.password.length > 1024) throw new Error('本机登录资料格式无效'); + return fields; +} diff --git a/web/src/features/home/AccountPanel.module.css b/web/src/features/home/AccountPanel.module.css index c07f6d0..07c10b0 100644 --- a/web/src/features/home/AccountPanel.module.css +++ b/web/src/features/home/AccountPanel.module.css @@ -77,6 +77,18 @@ grid-column: 1 / -1; } +.form .remember { + grid-column: 1 / -1; + flex-direction: row; + align-items: center; + gap: var(--space-2); +} + +.form .remember input { + padding: 0; + accent-color: var(--accent); +} + .error { color: var(--danger); } diff --git a/web/src/features/home/AccountPanel.tsx b/web/src/features/home/AccountPanel.tsx index 2d89a58..256b192 100644 --- a/web/src/features/home/AccountPanel.tsx +++ b/web/src/features/home/AccountPanel.tsx @@ -1,4 +1,5 @@ -import { useState, type FormEvent } from 'react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { accountApi } from '@/core-bridge/account'; import { useAccount } from '@/store/account'; import { useChannels } from '@/store/channels'; import { useShares } from '@/store/shares'; @@ -16,6 +17,8 @@ export function AccountPanel() { const login = useAccount((s) => s.login); const logout = useAccount((s) => s.logout); const sync = useAccount((s) => s.sync); + const remembered = useAccount((s) => s.remembered); + const clearSavedLogin = useAccount((s) => s.clearSavedLogin); const channelsReady = useChannels((s) => s.hydrated); const sharesReady = useShares((s) => s.hydrated); const refreshChannels = useChannels((s) => s.refresh); @@ -24,12 +27,31 @@ export function AccountPanel() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [message, setMessage] = useState(''); + const [remember, setRemember] = useState(true); + const [saved, setSaved] = useState(false); + const edited = useRef(false); + useEffect(() => { + if (account) return; + let active = true; + void accountApi.savedLogin().then((fields) => { + if (!active || edited.current) return; + setSaved(fields.saved); + if (fields.saved) { setUsername(fields.username); setPassword(fields.password); setRegister(false); setRemember(true); } + }).catch(() => { if (active) setMessage('本机保存读取失败,可手动输入登录。'); }); + return () => { active = false; }; + }, [account]); + async function clearSaved() { + edited.current = true; + try { await clearSavedLogin(); setSaved(false); setRemember(false); setUsername(''); setPassword(''); setMessage(account ? '本机保存已清除,当前页面仍可使用;刷新后需要重新登录。' : '已清除本机保存的账号和密码。'); } + catch { /* 错误由账号状态呈现 */ } + } + async function submit(event: FormEvent) { event.preventDefault(); if (busy) return; setMessage(''); try { - await login(username, password, register); + await login(username, password, register, remember); setPassword(''); await Promise.all([refreshChannels(), refreshShares()]); setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。'); @@ -52,14 +74,19 @@ export function AccountPanel() {
+ {remembered ? : null} +

{remembered ? '已开启本机记住登录。' : '仅本次登录,刷新或关闭页面后需重新登录。'}

) : (
{ void submit(event); }}> - - + + + +

仅在自己的设备使用。退出登录后仍可自动填充;“清除本机保存”会删除已保存资料。

- + + {saved ? : null}
)} diff --git a/web/src/store/account.ts b/web/src/store/account.ts index 5b4dc8f..7d242e8 100644 --- a/web/src/store/account.ts +++ b/web/src/store/account.ts @@ -11,9 +11,10 @@ import { initialShareData } from './shareReduce'; interface AccountState extends AccountStatus { busy: boolean; hydrated: boolean; - login: (username: string, password: string, create: boolean) => Promise; + login: (username: string, password: string, create: boolean, remember?: boolean) => Promise; refresh: () => Promise; logout: () => Promise; + clearSavedLogin: () => Promise; sync: () => Promise; updatePersonal: (displayName: string, avatar?: string) => Promise; } @@ -26,10 +27,10 @@ export const useAccount = create()((set, get) => ({ error: undefined, busy: false, hydrated: false, - login: async (username, password, create) => { + login: async (username, password, create, remember = true) => { set({ busy: true, error: undefined }); try { - const status = create ? await accountApi.register(username, password) : await accountApi.login(username, password); + const status = create ? await accountApi.register(username, password, remember) : await accountApi.login(username, password, remember); clearViews(); await refreshResources(); set({ ...status, busy: false, hydrated: true }); } catch (error) { @@ -48,13 +49,18 @@ export const useAccount = create()((set, get) => ({ logout: async () => { set({ busy: true }); try { await accountApi.logout(); clearViews(); await refreshResources(); } - finally { set({ username: null, personal: undefined, revision: 0, busy: false, error: undefined }); } + finally { set({ username: null, personal: undefined, remembered: false, revision: 0, busy: false, error: undefined }); } }, sync: async () => { set({ busy: true, error: undefined }); try { const status = await accountApi.sync(); await refreshResources(); set({ ...status, busy: false }); } catch (error) { set({ error: error instanceof Error ? error.message : '同步失败', busy: false }); throw error; } }, + clearSavedLogin: async () => { + set({ busy: true, error: undefined }); + try { const status = await accountApi.clearSavedLogin(); set({ ...status, busy: false }); } + catch (error) { set({ error: error instanceof Error ? error.message : '本机保存清除失败', busy: false }); throw error; } + }, updatePersonal: async (displayName, avatar) => { set({ busy: true, error: undefined }); try { const status = await accountApi.updatePersonal(displayName, avatar); set({ ...status, busy: false }); }