feat: add passkey-first login and management flow

This commit is contained in:
Shuai
2026-03-31 00:59:50 +08:00
parent 1184cb8d9a
commit 0f6da7d147
16 changed files with 799 additions and 6 deletions
+77 -3
View File
@@ -12,6 +12,10 @@ import {
getAuthorizedDevices,
getCurrentDeviceIdentifier,
getPasswordHint,
listAccountPasskeys,
registerAccountPasskey,
renameAccountPasskey,
deleteAccountPasskey,
getTotpStatus,
saveSession,
} from '@/lib/api/auth';
@@ -36,6 +40,7 @@ import {
type CompletedLogin,
readInitialAppBootstrapState,
performPasswordLogin,
performPasskeyLogin,
performRecoverTwoFactorLogin,
performRegistration,
performTotpLogin,
@@ -43,6 +48,7 @@ import {
type JwtUnsafeReason,
type PendingTotp,
} from '@/lib/app-auth';
import { passkeySupported } from '@/lib/passkey';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
import useAdminActions from '@/hooks/useAdminActions';
import useBackupActions from '@/hooks/useBackupActions';
@@ -153,6 +159,7 @@ export default function App() {
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingPasskeyTotp, setPendingPasskeyTotp] = useState(false);
const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true);
@@ -334,6 +341,7 @@ export default function App() {
setSession(login.session);
setProfile(login.profile);
setPendingTotp(null);
setPendingPasskeyTotp(false);
setTotpCode('');
setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
@@ -379,19 +387,53 @@ export default function App() {
}
async function handleTotpVerify() {
if (!pendingTotp) return;
if (!pendingTotp && !pendingPasskeyTotp) return;
if (!totpCode.trim()) {
pushToast('error', t('txt_please_input_totp_code'));
return;
}
try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
const login = pendingTotp
? await performTotpLogin(pendingTotp, totpCode, rememberDevice)
: (await (async () => {
const passkeyResult = await performPasskeyLogin(loginValues.email, totpCode);
if (passkeyResult.kind !== 'success') throw new Error(t('txt_totp_verify_failed'));
return passkeyResult.login;
})());
await finalizeLogin(login);
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
}
}
async function handlePasskeyLogin() {
if (pendingAuthAction) return;
if (!passkeySupported()) {
pushToast('error', '当前浏览器不支持 Passkey');
return;
}
setPendingAuthAction('login');
try {
const result = await performPasskeyLogin(loginValues.email);
if (result.kind === 'success') {
await finalizeLogin(result.login);
return;
}
if (result.kind === 'totp') {
setPendingPasskeyTotp(true);
setPendingTotp(null);
setTotpCode('');
setRememberDevice(false);
return;
}
pushToast('error', result.message || t('txt_login_failed'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
} finally {
setPendingAuthAction(null);
}
}
async function handleRecoverTwoFactorSubmit() {
const email = recoverValues.email.trim().toLowerCase();
const password = recoverValues.password;
@@ -527,6 +569,24 @@ export default function App() {
}
}
async function handleCreatePasskey(name: string) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error('请先解锁后再创建 Passkey');
await registerAccountPasskey(authedFetch, name, session);
await passkeysQuery.refetch();
pushToast('success', 'Passkey 已创建');
}
async function handleRenamePasskey(id: string, name: string) {
await renameAccountPasskey(authedFetch, id, name);
await passkeysQuery.refetch();
}
async function handleDeletePasskey(id: string) {
await deleteAccountPasskey(authedFetch, id);
await passkeysQuery.refetch();
pushToast('success', 'Passkey 已删除');
}
function handleLock() {
if (!session) return;
const nextSession = { ...session };
@@ -542,6 +602,7 @@ export default function App() {
setSession(null);
setProfile(null);
setPendingTotp(null);
setPendingPasskeyTotp(false);
setPhase('login');
navigate('/login');
}
@@ -616,6 +677,11 @@ export default function App() {
queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
});
const passkeysQuery = useQuery({
queryKey: ['account-passkeys', session?.accessToken],
queryFn: () => listAccountPasskeys(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
});
useEffect(() => {
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
@@ -1139,6 +1205,10 @@ export default function App() {
},
onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
passkeys: passkeysQuery.data || [],
onCreatePasskey: handleCreatePasskey,
onRenamePasskey: handleRenamePasskey,
onDeletePasskey: handleDeletePasskey,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onRemoveDevice: accountSecurityActions.openRemoveDevice,
@@ -1210,6 +1280,7 @@ export default function App() {
onChangeRegister={setRegisterValues}
onChangeUnlock={setUnlockPassword}
onSubmitLogin={() => void handleLogin()}
onSubmitPasskey={() => void handlePasskeyLogin()}
onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()}
onGotoLogin={() => {
@@ -1226,13 +1297,14 @@ export default function App() {
onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint}
passkeySupported={passkeySupported()}
/>
<AppGlobalOverlays
toasts={toasts}
onCloseToast={removeToast}
confirm={confirm}
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp}
pendingTotpOpen={!!pendingTotp || pendingPasskeyTotp}
totpCode={totpCode}
rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode}
@@ -1240,11 +1312,13 @@ export default function App() {
onConfirmTotp={() => void handleTotpVerify()}
onCancelTotp={() => {
setPendingTotp(null);
setPendingPasskeyTotp(false);
setTotpCode('');
setRememberDevice(true);
}}
onUseRecoveryCode={() => {
setPendingTotp(null);
setPendingPasskeyTotp(false);
setTotpCode('');
setRememberDevice(true);
navigate('/recover-2fa');
+8
View File
@@ -94,6 +94,10 @@ export interface AppMainRoutesProps {
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
onCreatePasskey: (name: string) => Promise<void>;
onRenamePasskey: (id: string, name: string) => Promise<void>;
onDeletePasskey: (id: string) => Promise<void>;
onRefreshAuthorizedDevices: () => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
@@ -225,6 +229,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode}
onNotify={props.onNotify}
passkeys={props.passkeys}
onCreatePasskey={props.onCreatePasskey}
onRenamePasskey={props.onRenamePasskey}
onDeletePasskey={props.onDeletePasskey}
/>
</Suspense>
</div>
+15 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import { ArrowLeft, Eye, EyeOff, Fingerprint, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -30,6 +30,7 @@ interface AuthViewsProps {
onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void;
onSubmitPasskey: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
@@ -37,6 +38,7 @@ interface AuthViewsProps {
onLogout: () => void;
onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void;
passkeySupported: boolean;
}
function PasswordField(props: {
@@ -106,6 +108,12 @@ export default function AuthViews(props: AuthViewsProps) {
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
</button>
{props.passkeySupported && (
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={unlockBusy}>
<Fingerprint size={16} className="btn-icon" />
Passkey
</button>
)}
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
<LogOut size={16} className="btn-icon" />
@@ -243,6 +251,12 @@ export default function AuthViews(props: AuthViewsProps) {
<LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button>
{props.passkeySupported && (
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy}>
<Fingerprint size={16} className="btn-icon" />
Passkey
</button>
)}
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
<UserPlus size={16} className="btn-icon" />
+32
View File
@@ -14,6 +14,10 @@ interface SettingsPageProps {
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void;
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
onCreatePasskey: (name: string) => Promise<void>;
onRenamePasskey: (id: string, name: string) => Promise<void>;
onDeletePasskey: (id: string) => Promise<void>;
}
function randomBase32Secret(length: number): string {
@@ -47,6 +51,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
const [passkeyName, setPasskeyName] = useState('');
useEffect(() => {
if (!props.totpEnabled) {
@@ -140,6 +145,33 @@ export default function SettingsPage(props: SettingsPageProps) {
</button>
</section>
<section className="card">
<h3>Passkey</h3>
<div className="field-grid">
<label className="field">
<span></span>
<input className="input" value={passkeyName} onInput={(e) => setPasskeyName((e.currentTarget as HTMLInputElement).value)} placeholder="例如:MacBook Touch ID" />
</label>
<div className="field" style={{ alignSelf: 'end' }}>
<button type="button" className="btn btn-primary" disabled={!passkeyName.trim()} onClick={() => void props.onCreatePasskey(passkeyName.trim()).then(() => setPasskeyName(''))}>
Passkey
</button>
</div>
</div>
<p className="muted-inline" style={{ marginBottom: 8 }}> 5 </p>
<div className="stack">
{props.passkeys.map((item) => (
<div key={item.id} className="card" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input className="input" style={{ flex: 1, minWidth: 180 }} value={item.name} onInput={(e) => void props.onRenamePasskey(item.id, (e.currentTarget as HTMLInputElement).value)} />
<button type="button" className="btn btn-danger" onClick={() => void props.onDeletePasskey(item.id)}></button>
</div>
</div>
))}
{!props.passkeys.length && <div className="empty"> Passkey</div>}
</div>
</section>
<section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">
+87
View File
@@ -8,6 +8,7 @@ import type {
TokenSuccess,
} from '../types';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
import { createPasskeyCredential, requestPasskeyAssertion } from '../passkey';
const SESSION_KEY = 'nodewarden.web.session.v4';
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
@@ -26,6 +27,14 @@ export interface PreloginKdfConfig {
kdfParallelism: number | null;
}
export interface AccountPasskey {
id: string;
name: string;
creationDate: string;
revisionDate: string;
lastUsedDate: string | null;
}
function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
@@ -197,6 +206,84 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenSuc
return json || null;
}
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskey[]> {
const resp = await authedFetch('/api/accounts/passkeys');
if (!resp.ok) throw new Error('Failed to load passkeys');
const body = (await parseJson<{ data?: AccountPasskey[] }>(resp)) || {};
return Array.isArray(body.data) ? body.data : [];
}
export async function registerAccountPasskey(authedFetch: AuthedFetch, name: string, session: SessionState): Promise<void> {
const beginResp = await authedFetch('/api/accounts/passkeys/begin-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!beginResp.ok) throw new Error('Failed to start passkey registration');
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
if (!begin.challengeId || !begin.publicKey) throw new Error('Invalid registration challenge');
const credential = await createPasskeyCredential(begin.publicKey);
const finishResp = await authedFetch('/api/accounts/passkeys/finish-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challengeId: begin.challengeId,
name,
wrappedVaultKeys: JSON.stringify({
symEncKey: session.symEncKey || '',
symMacKey: session.symMacKey || '',
}),
credential,
}),
});
if (!finishResp.ok) {
const err = await parseJson<TokenError>(finishResp);
throw new Error(err?.error_description || err?.error || 'Failed to finish passkey registration');
}
}
export async function renameAccountPasskey(authedFetch: AuthedFetch, passkeyId: string, name: string): Promise<void> {
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error('Failed to rename passkey');
}
export async function deleteAccountPasskey(authedFetch: AuthedFetch, passkeyId: string): Promise<void> {
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, { method: 'DELETE' });
if (!resp.ok && resp.status !== 204) throw new Error('Failed to delete passkey');
}
export async function loginWithPasskey(email?: string, totpCode?: string): Promise<TokenSuccess | TokenError> {
const beginResp = await fetch('/identity/passkeys/begin-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: String(email || '').trim().toLowerCase() || undefined }),
});
if (!beginResp.ok) return ((await parseJson<TokenError>(beginResp)) || {});
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
if (!begin.challengeId || !begin.publicKey) return { error: 'Passkey challenge missing' };
const credential = await requestPasskeyAssertion(begin.publicKey);
const finishResp = await fetch('/identity/passkeys/finish-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challengeId: begin.challengeId,
credential,
deviceIdentifier: getOrCreateDeviceIdentifier(),
deviceName: guessDeviceName(),
deviceType: '14',
twoFactorToken: totpCode || undefined,
}),
});
const result = (await parseJson<TokenSuccess & TokenError>(finishResp)) || {};
return result;
}
export async function registerAccount(args: {
email: string;
name: string;
+33
View File
@@ -4,6 +4,7 @@ import {
getProfile,
loadSession,
loginWithPassword,
loginWithPasskey,
refreshAccessToken,
recoverTwoFactor,
registerAccount,
@@ -46,6 +47,11 @@ export type PasswordLoginResult =
| { kind: 'totp'; pendingTotp: PendingTotp }
| { kind: 'error'; message: string };
export type PasskeyLoginResult =
| { kind: 'success'; login: CompletedLogin }
| { kind: 'totp' }
| { kind: 'error'; message: string };
export interface RecoverTwoFactorResult {
login: CompletedLogin | null;
newRecoveryCode: string | null;
@@ -359,3 +365,30 @@ export async function performUnlock(
}
return { ...refreshedSession, ...keys };
}
export async function performPasskeyLogin(email: string, totpCode?: string): Promise<PasskeyLoginResult> {
const token = await loginWithPasskey(email, totpCode);
if ('access_token' in token && token.access_token) {
const normalizedEmail = String(email || '').trim().toLowerCase();
const baseSession: SessionState = {
accessToken: token.access_token,
refreshToken: token.refresh_token,
email: normalizedEmail,
symEncKey: token.VaultKeys?.symEncKey,
symMacKey: token.VaultKeys?.symMacKey,
};
const tempFetch = createAuthedFetch(() => baseSession, () => {});
const profile = buildTransientProfile(token, normalizedEmail);
return {
kind: 'success',
login: {
session: baseSession,
profile,
profilePromise: getProfile(tempFetch),
},
};
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) return { kind: 'totp' };
return { kind: 'error', message: tokenError.error_description || tokenError.error || 'Passkey login failed' };
}
+72
View File
@@ -0,0 +1,72 @@
function base64UrlToBytes(input: string): Uint8Array {
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let binary = '';
for (const b of view) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function passkeySupported(): boolean {
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
}
export async function createPasskeyCredential(publicKey: Record<string, any>): Promise<any> {
const options: PublicKeyCredentialCreationOptions = {
...(publicKey as PublicKeyCredentialCreationOptions),
challenge: base64UrlToBytes(publicKey.challenge),
user: {
...publicKey.user,
id: base64UrlToBytes(publicKey.user.id),
},
excludeCredentials: Array.isArray(publicKey.excludeCredentials)
? publicKey.excludeCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
: [],
};
const credential = (await navigator.credentials.create({ publicKey: options })) as PublicKeyCredential | null;
if (!credential) throw new Error('Passkey creation was cancelled');
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: bytesToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
attestationObject: bytesToBase64Url(response.attestationObject),
},
};
}
export async function requestPasskeyAssertion(publicKey: Record<string, any>): Promise<any> {
const options: PublicKeyCredentialRequestOptions = {
...(publicKey as PublicKeyCredentialRequestOptions),
challenge: base64UrlToBytes(publicKey.challenge),
allowCredentials: Array.isArray(publicKey.allowCredentials)
? publicKey.allowCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
: undefined,
};
const credential = (await navigator.credentials.get({ publicKey: options })) as PublicKeyCredential | null;
if (!credential) throw new Error('Passkey login was cancelled');
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: bytesToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
authenticatorData: bytesToBase64Url(response.authenticatorData),
signature: bytesToBase64Url(response.signature),
userHandle: response.userHandle ? bytesToBase64Url(response.userHandle) : null,
},
};
}
+4
View File
@@ -290,6 +290,10 @@ export interface TokenSuccess {
unofficialServer?: boolean;
UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown;
VaultKeys?: {
symEncKey?: string;
symMacKey?: string;
};
}
export interface TokenError {