feat(web): TOTP QR code and recovery codes, passkey management and passkey login
- Account & security: TOTP setup renders the otpauth URL as a QR code (qr-code-styling) with the secret as a fallback, shows the 8 recovery codes once after enabling (copy / download), and offers regenerate and disable (verification code or recovery code) when enabled. - New Passkey block: add (named, @simplewebauthn/browser), rename, delete; hidden outside a secure context. - Login page: "用 Passkey 登录" when a passkey is registered and the browser supports it; the TOTP step can switch to a recovery code. - Account menu links to the settings block instead of its own TOTP dialog, so enabling always shows the recovery codes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b0e5d539ed
commit
dd0167fe85
6 files changed
+739
-308
No files matched your search
@@ -1,13 +1,26 @@
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
browserSupportsWebAuthn,
|
||||
type PublicKeyCredentialCreationOptionsJSON,
|
||||
type PublicKeyCredentialRequestOptionsJSON,
|
||||
type RegistrationResponseJSON,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
AdminSetupRequest,
|
||||
LoginRequest,
|
||||
LoginResult,
|
||||
PasskeyChallenge,
|
||||
PasskeyInfo,
|
||||
PasskeyList,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResult,
|
||||
SessionInfo,
|
||||
TotpAction,
|
||||
TotpActionResult,
|
||||
TotpSetup,
|
||||
TotpStatus,
|
||||
} from "@umt/contracts";
|
||||
import { ApiRequestError, apiData, apiFetch } from "~/lib/api.ts";
|
||||
@@ -69,3 +82,89 @@ export function useTotpAction() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 待确认的 TOTP 密钥与 otpauth URL(没有就由服务端生成;刷新不作废) */
|
||||
export function useTotpSetup(enabled: boolean) {
|
||||
return useQuery({
|
||||
// 不挂在 "auth" 下:开启成功后 invalidate("auth") 不该再去取(已开启会 409);不缓存,每次进入绑定都向服务端要
|
||||
// (服务端对未确认的密钥原样返回,刷新不会换码)
|
||||
queryKey: qk.sub("totp-setup"),
|
||||
queryFn: ({ signal }) => apiData<TotpSetup>("/auth/totp/setup", { signal, noAuthRedirect: true }),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Passkey ──
|
||||
|
||||
/** 浏览器能不能用 Passkey:要安全上下文(HTTPS 或 localhost)且支持 WebAuthn */
|
||||
export function passkeySupported(): boolean {
|
||||
return typeof window !== "undefined" && window.isSecureContext && browserSupportsWebAuthn();
|
||||
}
|
||||
|
||||
/** 用户在系统弹窗里取消 / 超时:不当成错误提示 */
|
||||
export function isPasskeyCancel(err: unknown): boolean {
|
||||
const name = (err as { name?: string; cause?: { name?: string } })?.name;
|
||||
const cause = (err as { cause?: { name?: string } })?.cause?.name;
|
||||
return name === "NotAllowedError" || cause === "NotAllowedError" || name === "AbortError";
|
||||
}
|
||||
|
||||
/** Passkey 登录:取挑战 → 浏览器签名 → 校验;成功后服务端下发会话 cookie */
|
||||
export async function loginWithPasskey(): Promise<LoginResult> {
|
||||
const ch = await authPost<PasskeyChallenge>("/auth/passkeys/login/options", {});
|
||||
const response: AuthenticationResponseJSON = await startAuthentication({
|
||||
optionsJSON: ch.options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
});
|
||||
return authPost<LoginResult>("/auth/passkeys/login/verify", { challengeId: ch.challengeId, response });
|
||||
}
|
||||
|
||||
export function usePasskeys(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: qk.sub("auth", "passkeys"),
|
||||
queryFn: ({ signal }) => apiData<PasskeyList>("/auth/passkeys", { signal }),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegisterPasskey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const ch = await apiData<PasskeyChallenge>("/auth/passkeys/register/options", { method: "POST", body: {} });
|
||||
const response: RegistrationResponseJSON = await startRegistration({
|
||||
optionsJSON: ch.options as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||
});
|
||||
return apiData<PasskeyInfo>("/auth/passkeys/register/verify", {
|
||||
method: "POST",
|
||||
body: { challengeId: ch.challengeId, name, response },
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("auth") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenamePasskey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (v: { id: string; name: string }) =>
|
||||
apiData<PasskeyInfo>(`/auth/passkeys/${v.id}`, { method: "PATCH", body: { name: v.name } }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.sub("auth", "passkeys") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePasskey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiData<unknown>(`/auth/passkeys/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("auth") });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user