feat: support secure passkey login (#561)

Add WebAuthn passkey enrollment and login for issue #536, with trusted origin configuration, user verification, single-use challenges, and documented deployment and recovery behavior.
This commit is contained in:
sagit
2026-09-30 19:23:10 +08:00
committed by GitHub
parent c952d2fb3a
commit 861d61239a
19 changed files with 1150 additions and 5 deletions
+68 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile";
@@ -10,8 +10,17 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { BrandLogo } from "@/components/brand-logo";
import { login, LoginData, checkCaptcha, getPublicConfigByName } from "@/api";
import {
login,
LoginData,
checkCaptcha,
getPublicConfigByName,
getPasskeyStatus,
beginPasskeyLogin,
finishPasskeyLogin,
} from "@/api";
import { writeLoginSession } from "@/utils/session";
import { getPasskey } from "@/utils/passkey";
import { useWebViewMode } from "@/hooks/useWebViewMode";
interface LoginForm {
@@ -30,9 +39,56 @@ export default function IndexPage() {
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState("");
const [passkeyEnabled, setPasskeyEnabled] = useState(false);
const navigate = useNavigate();
const isWebView = useWebViewMode();
useEffect(() => {
if (
window.isSecureContext &&
typeof window.PublicKeyCredential !== "undefined"
) {
getPasskeyStatus()
.then((res) => setPasskeyEnabled(res.code === 0 && res.data.enabled))
.catch(() => setPasskeyEnabled(false));
}
}, []);
const handlePasskeyLogin = async () => {
if (!form.username.trim()) {
toast.error("请输入用户名");
return;
}
setLoading(true);
try {
const begin = await beginPasskeyLogin(form.username.trim());
if (begin.code !== 0) {
toast.error(begin.msg || "无法使用通行证密钥登录");
return;
}
const credential = await getPasskey(begin.data.options);
const result = await finishPasskeyLogin(begin.data.sessionId, credential);
if (result.code !== 0) {
toast.error(result.msg || "通行证密钥登录失败");
return;
}
writeLoginSession(result.data);
toast.success("登录成功");
navigate(
result.data.requirePasswordChange ? "/change-password" : "/dashboard",
);
} catch {
toast.error("通行证密钥登录已取消或失败");
} finally {
setLoading(false);
}
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -221,6 +277,16 @@ export default function IndexPage() {
: "Signing in..."
: "Sign In"}
</Button>
{passkeyEnabled && (
<Button
className="h-12 rounded-xl"
disabled={loading}
variant="bordered"
onPress={handlePasskeyLogin}
>
使用通行证密钥登录
</Button>
)}
</div>
</CardBody>
</Card>
+2
View File
@@ -19,6 +19,7 @@ import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
import { PasskeyManager } from "@/components/passkey-manager";
interface PasswordForm {
newUsername: string;
currentPassword: string;
@@ -241,6 +242,7 @@ export default function ProfilePage() {
</Card>
{/* 功能网格 */}
<PasskeyManager />
<Card>
<CardBody className="p-4">
<div className="grid grid-cols-3 gap-3">