feat(auth): remember encrypted local login credentials

This commit is contained in:
justme committed 2026-10-13 01:39:24 +08:00
1 parent f6cfb12716
commit b65e32885f
9 files changed
+148 -30

No files matched your search

+5 -3
View File
@@ -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<AccountStatus>('registerAccount', { username, password, apiBase: API_BASE }),
login: (username: string, password: string) => callWorker<AccountStatus>('loginAccount', { username, password, apiBase: API_BASE }),
register: (username: string, password: string, remember = true) => callWorker<AccountStatus>('registerAccount', { username, password, remember, apiBase: API_BASE }),
login: (username: string, password: string, remember = true) => callWorker<AccountStatus>('loginAccount', { username, password, remember, apiBase: API_BASE }),
savedLogin: () => callWorker<{ username: string; password: string; saved: boolean }>('savedLogin', { apiBase: API_BASE }),
clearSavedLogin: () => callWorker<AccountStatus>('clearSavedLogin', { apiBase: API_BASE }),
status: () => callWorker<AccountStatus>('accountStatus', { apiBase: API_BASE }),
logout: () => callWorker<void>('logoutAccount', { apiBase: API_BASE }),
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
+5 -3
View File
@@ -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<unknown> {
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();
+32 -13
View File
@@ -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<void>; error?: string;
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
}
@@ -31,6 +32,10 @@ async function applyProfile(profile: VaultProfile): Promise<void> {
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<void> {
if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, ...value });
else await forgetAccount();
}
async function syncVault(): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
@@ -48,7 +53,7 @@ async function syncVault(): Promise<void> {
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<void> {
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<void> {
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<AccountSession> {
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise<AccountSession> {
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<void> {
try {
remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise<VaultBody>);
} 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<Awaited<ReturnType<typeof accountStatus>>> {
export async function registerAccount(input: string, password: string, remember = true): Promise<Awaited<ReturnType<typeof accountStatus>>> {
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<Awaited<ReturnType<typeof accountStatus>>> {
export async function loginAccount(input: string, password: string, remember = true): Promise<Awaited<ReturnType<typeof accountStatus>>> {
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<Awa
const publicKey = await apiFetch(`/accounts/users/${username}/key`, { headers: headers(identity) }).then((r) => 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<Awa
throw error;
}
}
export async function accountStatus(): Promise<{ username: string | null; revision: number; personal?: PersonalProfile; error?: string }> {
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<void> {
if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {});
}
export async function syncAccount(): Promise<void> { 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<void> {
if (account) {
account.remember = false;
await account.syncing?.catch(() => {});
}
await forgetAccount(); await forgetLoginFields();
}
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
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<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
@@ -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<LocalIdentity>('webdrop-account-local-2', 1, { upgrade(db) {
db.createObjectStore('sessions'); db.createObjectStore('device');
const database = () => openDB<LocalIdentity>('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<RememberedAccount | undefined> { return (await database()).get('sessions', endpoint()); }
export async function rememberAccount(value: RememberedAccount): Promise<void> { await (await database()).put('sessions', value, endpoint()); }
export async function forgetAccount(): Promise<void> { await (await database()).delete('sessions', endpoint()); }
export async function saveLoginFields(username: string, password: string): Promise<void> {
const value = await encryptLogin({ username, password }, endpoint()); await (await database()).put('credentials', value, endpoint());
}
export async function forgetLoginFields(): Promise<void> { await (await database()).delete('credentials', endpoint()); }
export async function savedLoginFields(): Promise<LoginFields | undefined> {
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());
@@ -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));
});
});
@@ -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<EncryptedLogin> {
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<LoginFields> {
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;
}
@@ -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);
}
+32 -5
View File
@@ -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<HTMLFormElement>) {
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() {
<div className={styles.actions}>
<Button disabled={busy} onClick={() => { void sync().then(() => setMessage('资源保险库已同步。')).catch(() => {}); }}>{busy ? '同步中…' : '立即同步'}</Button>
<Button variant="ghost" disabled={busy} onClick={() => { void logout().then(() => navigate('/')); setMessage(''); }}>退出登录</Button>
{remembered ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
<p className={styles.note}>{remembered ? '已开启本机记住登录。' : '仅本次登录,刷新或关闭页面后需重新登录。'}</p>
</div>
) : (
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
<label>用户名<input autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label>密码<input autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
<label>用户名<input name="username" autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
<label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label>
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
<p className={styles.note}>仅在自己的设备使用。退出登录后仍可自动填充;“清除本机保存”会删除已保存资料。</p>
<div className={styles.actions}>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : register ? '创建账号' : '登录并同步'}</Button>
<Button variant="ghost" onClick={() => { setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
<Button variant="ghost" onClick={() => { edited.current = true; setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
{saved ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
</div>
</form>
)}
+10 -4
View File
@@ -11,9 +11,10 @@ import { initialShareData } from './shareReduce';
interface AccountState extends AccountStatus {
busy: boolean;
hydrated: boolean;
login: (username: string, password: string, create: boolean) => Promise<void>;
login: (username: string, password: string, create: boolean, remember?: boolean) => Promise<void>;
refresh: () => Promise<void>;
logout: () => Promise<void>;
clearSavedLogin: () => Promise<void>;
sync: () => Promise<void>;
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
}
@@ -26,10 +27,10 @@ export const useAccount = create<AccountState>()((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<AccountState>()((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 }); }